feat: 实现完整的考试系统
实现了包含试卷管理、考试答题、AI智能阅卷的完整考试流程。 **后端新增功能**: - 试卷管理: 创建试卷、获取试卷列表和详情 - 考试流程: 开始考试、提交答案、查询结果 - AI阅卷: 异步阅卷系统,支持简答题和论述题AI评分 - 实时答题: 题目级别的答案保存和加载 - 数据模型: ExamRecord(考试记录)、ExamUserAnswer(用户答案) **前端新增页面**: - 考试管理页面: 试卷列表展示,支持开始/继续考试 - 答题页面: 左侧题目列表、右侧答题区,支持实时保存 - 成绩查看页面: 展示详细评分结果和AI评语 **技术亮点**: - 按题型固定分值配置(总分100分) - 异步阅卷机制,提交后立即返回 - 答案实时保存,支持断点续答 - AI评分集成,智能评判主观题 - 响应式设计,适配移动端和PC端 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
f8ce822436
commit
4c06a8acd5
5
go.mod
5
go.mod
@ -10,6 +10,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
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
12
go.sum
@ -1,3 +1,5 @@
|
|||||||
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
github.com/bytedance/gopkg v0.1.3 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=
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
257
internal/handlers/exam_grading.go
Normal file
257
internal/handlers/exam_grading.go
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"ankao/internal/database"
|
||||||
|
"ankao/internal/models"
|
||||||
|
"ankao/internal/services"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"gorm.io/datatypes"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReGradeExam 公开的重新阅卷函数,可被外部调用
|
||||||
|
func ReGradeExam(recordID uint, examID uint, userID uint) {
|
||||||
|
gradeExam(recordID, examID, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// gradeExam 异步阅卷函数
|
||||||
|
func gradeExam(recordID uint, examID uint, userID uint) {
|
||||||
|
db := database.GetDB()
|
||||||
|
|
||||||
|
// 查询考试记录
|
||||||
|
var record models.ExamRecord
|
||||||
|
if err := db.Where("id = ?", recordID).First(&record).Error; err != nil {
|
||||||
|
log.Printf("查询考试记录失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询试卷
|
||||||
|
var exam models.Exam
|
||||||
|
if err := db.Where("id = ?", examID).First(&exam).Error; err != nil {
|
||||||
|
log.Printf("查询试卷失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 ExamUserAnswer 表读取所有答案
|
||||||
|
var userAnswers []models.ExamUserAnswer
|
||||||
|
if err := db.Where("exam_record_id = ?", recordID).Find(&userAnswers).Error; err != nil {
|
||||||
|
log.Printf("查询用户答案失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为 map 格式方便查找
|
||||||
|
answersMap := make(map[uint]interface{})
|
||||||
|
for _, ua := range userAnswers {
|
||||||
|
var answer interface{}
|
||||||
|
if err := json.Unmarshal(ua.Answer, &answer); err != nil {
|
||||||
|
log.Printf("解析答案失败: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
answersMap[ua.QuestionID] = answer
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析题目ID列表
|
||||||
|
var questionIDs []uint
|
||||||
|
if err := json.Unmarshal(exam.QuestionIDs, &questionIDs); err != nil {
|
||||||
|
log.Printf("解析题目ID失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询题目详情
|
||||||
|
var questions []models.PracticeQuestion
|
||||||
|
if err := db.Where("id IN ?", questionIDs).Find(&questions).Error; err != nil {
|
||||||
|
log.Printf("查询题目失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用固定的题型分值映射
|
||||||
|
scoreMap := map[string]float64{
|
||||||
|
"fill-in-blank": 2.0,
|
||||||
|
"true-false": 2.0,
|
||||||
|
"multiple-choice": 1.0,
|
||||||
|
"multiple-selection": 2.5,
|
||||||
|
"short-answer": 10.0,
|
||||||
|
"ordinary-essay": 5.0,
|
||||||
|
"management-essay": 5.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 评分
|
||||||
|
totalScore := 0.0
|
||||||
|
aiService := services.NewAIGradingService()
|
||||||
|
|
||||||
|
for _, question := range questions {
|
||||||
|
userAnswerRaw, answered := answersMap[question.ID]
|
||||||
|
|
||||||
|
if !answered {
|
||||||
|
// 更新数据库中的 ExamUserAnswer 记录为未作答
|
||||||
|
var userAnswer models.ExamUserAnswer
|
||||||
|
result := db.Where("exam_record_id = ? AND question_id = ?", recordID, question.ID).First(&userAnswer)
|
||||||
|
if result.Error == nil {
|
||||||
|
updates := map[string]interface{}{
|
||||||
|
"is_correct": false,
|
||||||
|
"score": 0.0,
|
||||||
|
}
|
||||||
|
db.Model(&userAnswer).Updates(updates)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据题型判断答案
|
||||||
|
var isCorrect bool
|
||||||
|
var score float64
|
||||||
|
var aiGrading *models.AIGrading
|
||||||
|
|
||||||
|
switch question.Type {
|
||||||
|
case "fill-in-blank":
|
||||||
|
// 填空题:比较数组
|
||||||
|
userAnswerArr, ok := userAnswerRaw.([]interface{})
|
||||||
|
if !ok {
|
||||||
|
isCorrect = false
|
||||||
|
score = 0
|
||||||
|
// 更新数据库
|
||||||
|
var userAnswer models.ExamUserAnswer
|
||||||
|
if result := db.Where("exam_record_id = ? AND question_id = ?", recordID, question.ID).First(&userAnswer); result.Error == nil {
|
||||||
|
db.Model(&userAnswer).Updates(map[string]interface{}{
|
||||||
|
"is_correct": false,
|
||||||
|
"score": 0.0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var correctAnswers []string
|
||||||
|
if err := json.Unmarshal([]byte(question.AnswerData), &correctAnswers); err != nil {
|
||||||
|
log.Printf("解析填空题答案失败: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
isCorrect = len(userAnswerArr) == len(correctAnswers)
|
||||||
|
if isCorrect {
|
||||||
|
for i, ua := range userAnswerArr {
|
||||||
|
if i >= len(correctAnswers) || fmt.Sprintf("%v", ua) != correctAnswers[i] {
|
||||||
|
isCorrect = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isCorrect {
|
||||||
|
score = scoreMap["fill-in-blank"]
|
||||||
|
}
|
||||||
|
|
||||||
|
case "true-false":
|
||||||
|
// 判断题 - AnswerData 直接存储 "true" 或 "false" 字符串
|
||||||
|
correctAnswer := question.AnswerData
|
||||||
|
isCorrect = fmt.Sprintf("%v", userAnswerRaw) == correctAnswer
|
||||||
|
if isCorrect {
|
||||||
|
score = scoreMap["true-false"]
|
||||||
|
}
|
||||||
|
|
||||||
|
case "multiple-choice":
|
||||||
|
correctAnswer := question.AnswerData
|
||||||
|
isCorrect = fmt.Sprintf("\"%v\"", userAnswerRaw) == correctAnswer
|
||||||
|
if isCorrect {
|
||||||
|
score = scoreMap["multiple-choice"]
|
||||||
|
}
|
||||||
|
case "multiple-selection":
|
||||||
|
// 多选题:比较数组(顺序无关)
|
||||||
|
userAnswerArr, ok := userAnswerRaw.([]interface{})
|
||||||
|
if !ok {
|
||||||
|
isCorrect = false
|
||||||
|
score = 0
|
||||||
|
// 更新数据库
|
||||||
|
var userAnswer models.ExamUserAnswer
|
||||||
|
if result := db.Where("exam_record_id = ? AND question_id = ?", recordID, question.ID).First(&userAnswer); result.Error == nil {
|
||||||
|
db.Model(&userAnswer).Updates(map[string]interface{}{
|
||||||
|
"is_correct": false,
|
||||||
|
"score": 0.0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var correctAnswers []string
|
||||||
|
if err := json.Unmarshal([]byte(question.AnswerData), &correctAnswers); err != nil {
|
||||||
|
log.Printf("解析多选题答案失败: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
userAnswerSet := make(map[string]bool)
|
||||||
|
for _, ua := range userAnswerArr {
|
||||||
|
userAnswerSet[fmt.Sprintf("%v", ua)] = true
|
||||||
|
}
|
||||||
|
isCorrect = len(userAnswerSet) == len(correctAnswers)
|
||||||
|
if isCorrect {
|
||||||
|
for _, ca := range correctAnswers {
|
||||||
|
if !userAnswerSet[ca] {
|
||||||
|
isCorrect = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isCorrect {
|
||||||
|
score = scoreMap["multiple-selection"]
|
||||||
|
}
|
||||||
|
|
||||||
|
case "short-answer", "ordinary-essay", "management-essay":
|
||||||
|
// 简答题和论述题:使用AI评分
|
||||||
|
// AnswerData 直接存储答案文本
|
||||||
|
correctAnswer := question.AnswerData
|
||||||
|
userAnswerStr := fmt.Sprintf("%v", userAnswerRaw)
|
||||||
|
aiResult, err := aiService.GradeShortAnswer(question.Question, correctAnswer, userAnswerStr)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("AI评分失败: %v", err)
|
||||||
|
isCorrect = false
|
||||||
|
score = 0
|
||||||
|
} else {
|
||||||
|
isCorrect = aiResult.IsCorrect
|
||||||
|
// 按AI评分比例计算
|
||||||
|
var questionScore float64
|
||||||
|
if question.Type == "short-answer" {
|
||||||
|
questionScore = scoreMap["short-answer"]
|
||||||
|
} else if question.Type == "ordinary-essay" {
|
||||||
|
questionScore = scoreMap["ordinary-essay"]
|
||||||
|
} else if question.Type == "management-essay" {
|
||||||
|
questionScore = scoreMap["management-essay"]
|
||||||
|
}
|
||||||
|
score = questionScore * (aiResult.Score / 100.0)
|
||||||
|
aiGrading = &models.AIGrading{
|
||||||
|
Score: aiResult.Score,
|
||||||
|
Feedback: aiResult.Feedback,
|
||||||
|
Suggestion: aiResult.Suggestion,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
totalScore += score
|
||||||
|
|
||||||
|
// 更新数据库中的 ExamUserAnswer 记录
|
||||||
|
var userAnswer models.ExamUserAnswer
|
||||||
|
result := db.Where("exam_record_id = ? AND question_id = ?", recordID, question.ID).First(&userAnswer)
|
||||||
|
if result.Error == nil {
|
||||||
|
// 序列化 AI 评分数据
|
||||||
|
var aiGradingJSON datatypes.JSON
|
||||||
|
if aiGrading != nil {
|
||||||
|
aiGradingData, _ := json.Marshal(aiGrading)
|
||||||
|
aiGradingJSON = datatypes.JSON(aiGradingData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新评分结果
|
||||||
|
updates := map[string]interface{}{
|
||||||
|
"is_correct": isCorrect,
|
||||||
|
"score": score,
|
||||||
|
"ai_grading_data": aiGradingJSON,
|
||||||
|
}
|
||||||
|
db.Model(&userAnswer).Updates(updates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存分数和状态到考试记录
|
||||||
|
record.Score = totalScore
|
||||||
|
record.Status = "graded"
|
||||||
|
record.IsPassed = totalScore >= float64(exam.PassScore)
|
||||||
|
|
||||||
|
if err := db.Save(&record).Error; err != nil {
|
||||||
|
log.Printf("保存考试记录失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("阅卷完成: 考试记录ID=%d, 总分=%.2f, 是否通过=%v", recordID, totalScore, record.IsPassed)
|
||||||
|
}
|
||||||
@ -3,7 +3,6 @@ package handlers
|
|||||||
import (
|
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:
|
||||||
|
// 字符串类型:直接使用 AnswerData(true-false, short-answer, essay)
|
||||||
|
correctAnswerRaw = question.AnswerData
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建答案对象
|
||||||
|
examAnswer := models.ExamAnswer{
|
||||||
|
QuestionID: ua.QuestionID,
|
||||||
|
Answer: userAnswer,
|
||||||
|
CorrectAnswer: correctAnswerRaw,
|
||||||
|
IsCorrect: ua.IsCorrect != nil && *ua.IsCorrect,
|
||||||
|
Score: ua.Score,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加 AI 评分信息
|
||||||
|
if len(ua.AIGradingData) > 0 {
|
||||||
|
var aiGrading models.AIGrading
|
||||||
|
if err := json.Unmarshal(ua.AIGradingData, &aiGrading); err == nil {
|
||||||
|
examAnswer.AIGrading = &aiGrading
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
totalScore += score
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@ -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
15
main.go
@ -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(需要管理员权限)
|
||||||
|
|||||||
@ -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={
|
||||||
|
|||||||
@ -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')
|
||||||
|
|||||||
105
web/src/pages/ExamManagement.module.less
Normal file
105
web/src/pages/ExamManagement.module.less
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
.container {
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin: 0;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardTitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardContent {
|
||||||
|
.description {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 24px 0 16px 0;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 响应式适配
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardTitle {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardContent {
|
||||||
|
.description {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statItem {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
398
web/src/pages/ExamManagement.tsx
Normal file
398
web/src/pages/ExamManagement.tsx
Normal file
@ -0,0 +1,398 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Button,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Space,
|
||||||
|
message,
|
||||||
|
List,
|
||||||
|
Tag,
|
||||||
|
Modal,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
Empty,
|
||||||
|
Spin,
|
||||||
|
Drawer,
|
||||||
|
Descriptions,
|
||||||
|
Divider
|
||||||
|
} from 'antd'
|
||||||
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
PlayCircleOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
TrophyOutlined,
|
||||||
|
HistoryOutlined
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import * as examApi from '../api/exam'
|
||||||
|
import styles from './ExamManagement.module.less'
|
||||||
|
|
||||||
|
interface ExamListItem {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
total_score: number
|
||||||
|
duration: number
|
||||||
|
pass_score: number
|
||||||
|
question_count: number
|
||||||
|
attempt_count: number
|
||||||
|
best_score: number
|
||||||
|
has_in_progress_exam: boolean
|
||||||
|
in_progress_record_id?: number
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExamManagement: React.FC = () => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [exams, setExams] = useState<ExamListItem[]>([])
|
||||||
|
const [loadingExams, setLoadingExams] = useState(false)
|
||||||
|
const [createModalVisible, setCreateModalVisible] = useState(false)
|
||||||
|
const [recordsDrawerVisible, setRecordsDrawerVisible] = useState(false)
|
||||||
|
const [, setCurrentExamId] = useState<number | null>(null)
|
||||||
|
const [examRecords, setExamRecords] = useState<any[]>([])
|
||||||
|
const [loadingRecords, setLoadingRecords] = useState(false)
|
||||||
|
|
||||||
|
// 加载试卷列表
|
||||||
|
const loadExams = async () => {
|
||||||
|
setLoadingExams(true)
|
||||||
|
try {
|
||||||
|
const res = await examApi.getExamList()
|
||||||
|
if (res.success) {
|
||||||
|
setExams(res.data || [])
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('加载试卷列表失败')
|
||||||
|
} finally {
|
||||||
|
setLoadingExams(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadExams()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 创建试卷
|
||||||
|
const handleCreateExam = async (values: any) => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
title: values.title,
|
||||||
|
duration: 60, // 默认60分钟
|
||||||
|
question_types: [] // 空配置,后端会使用默认值
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await examApi.createExam(params)
|
||||||
|
if (res.success) {
|
||||||
|
message.success('试卷创建成功')
|
||||||
|
setCreateModalVisible(false)
|
||||||
|
form.resetFields()
|
||||||
|
loadExams()
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '创建失败')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.response?.data?.message) {
|
||||||
|
message.error(error.response.data.message)
|
||||||
|
} else {
|
||||||
|
message.error('创建失败,请稍后重试')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除试卷
|
||||||
|
const handleDeleteExam = async (examId: number) => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认删除',
|
||||||
|
content: '删除试卷后将无法恢复,是否确认删除?',
|
||||||
|
okText: '确认',
|
||||||
|
cancelText: '取消',
|
||||||
|
okType: 'danger',
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
const res = await examApi.deleteExam(examId)
|
||||||
|
if (res.success) {
|
||||||
|
message.success('删除成功')
|
||||||
|
loadExams()
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '删除失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始考试
|
||||||
|
const handleStartExam = async (examId: number, hasInProgressExam: boolean, inProgressRecordId?: number) => {
|
||||||
|
try {
|
||||||
|
if (hasInProgressExam && inProgressRecordId) {
|
||||||
|
// 有未完成的考试,直接跳转继续答题
|
||||||
|
navigate(`/exam/${examId}/taking/${inProgressRecordId}`)
|
||||||
|
} else {
|
||||||
|
// 没有未完成的考试,调用开始考试API创建新记录
|
||||||
|
const res = await examApi.startExam(examId)
|
||||||
|
if (res.success && res.data) {
|
||||||
|
navigate(`/exam/${examId}/taking/${res.data.record_id}`)
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '开始考试失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('开始考试失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查看考试记录
|
||||||
|
const handleViewRecords = async (examId: number) => {
|
||||||
|
setCurrentExamId(examId)
|
||||||
|
setRecordsDrawerVisible(true)
|
||||||
|
setLoadingRecords(true)
|
||||||
|
try {
|
||||||
|
const res = await examApi.getExamRecordList(examId)
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setExamRecords(res.data)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('加载考试记录失败')
|
||||||
|
} finally {
|
||||||
|
setLoadingRecords(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查看记录详情
|
||||||
|
const handleViewRecordDetail = (recordId: number) => {
|
||||||
|
navigate(`/exam/result/${recordId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<Card>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div>
|
||||||
|
<h2>模拟考试</h2>
|
||||||
|
<p className={styles.subtitle}>创建和管理模拟试卷</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => setCreateModalVisible(true)}
|
||||||
|
>
|
||||||
|
创建试卷
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Spin spinning={loadingExams}>
|
||||||
|
{exams.length === 0 ? (
|
||||||
|
<Empty
|
||||||
|
description="暂无试卷,点击上方按钮创建"
|
||||||
|
style={{ marginTop: 40 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<List
|
||||||
|
grid={{ gutter: 16, xs: 1, sm: 1, md: 2, lg: 2, xl: 3, xxl: 3 }}
|
||||||
|
dataSource={exams}
|
||||||
|
renderItem={(exam) => (
|
||||||
|
<List.Item>
|
||||||
|
<Card
|
||||||
|
hoverable
|
||||||
|
actions={[
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
icon={<PlayCircleOutlined />}
|
||||||
|
onClick={() => handleStartExam(exam.id, exam.has_in_progress_exam, exam.in_progress_record_id)}
|
||||||
|
>
|
||||||
|
{exam.has_in_progress_exam ? '继续考试' : '开始考试'}
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
icon={<HistoryOutlined />}
|
||||||
|
onClick={() => handleViewRecords(exam.id)}
|
||||||
|
>
|
||||||
|
考试记录
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={() => handleDeleteExam(exam.id)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Card.Meta
|
||||||
|
title={
|
||||||
|
<div className={styles.cardTitle}>
|
||||||
|
<FileTextOutlined />
|
||||||
|
<span>{exam.title}</span>
|
||||||
|
{exam.has_in_progress_exam && (
|
||||||
|
<Tag color="processing" style={{ marginLeft: 8 }}>进行中</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
<div className={styles.cardContent}>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col span={12}>
|
||||||
|
<div className={styles.statItem}>
|
||||||
|
<ClockCircleOutlined />
|
||||||
|
<span>{exam.duration} 分钟</span>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<div className={styles.statItem}>
|
||||||
|
<CheckCircleOutlined />
|
||||||
|
<span>及格 {exam.pass_score} 分</span>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<div className={styles.stats}>
|
||||||
|
<Tag icon={<TrophyOutlined />} color="gold">
|
||||||
|
最高分: {exam.best_score || 0}
|
||||||
|
</Tag>
|
||||||
|
<Tag color="blue">已考 {exam.attempt_count} 次</Tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Spin>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 考试记录抽屉 */}
|
||||||
|
<Drawer
|
||||||
|
title="考试记录"
|
||||||
|
placement="right"
|
||||||
|
width={600}
|
||||||
|
open={recordsDrawerVisible}
|
||||||
|
onClose={() => setRecordsDrawerVisible(false)}
|
||||||
|
>
|
||||||
|
<Spin spinning={loadingRecords}>
|
||||||
|
{examRecords.length === 0 ? (
|
||||||
|
<Empty description="暂无考试记录" />
|
||||||
|
) : (
|
||||||
|
<List
|
||||||
|
dataSource={examRecords}
|
||||||
|
renderItem={(record: any) => (
|
||||||
|
<Card
|
||||||
|
key={record.id}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<Descriptions column={1} size="small">
|
||||||
|
<Descriptions.Item label="状态">
|
||||||
|
{record.status === 'in_progress' && <Tag color="processing">进行中</Tag>}
|
||||||
|
{record.status === 'submitted' && <Tag color="warning">已提交</Tag>}
|
||||||
|
{record.status === 'graded' && (
|
||||||
|
<Tag color={record.is_passed ? 'success' : 'error'}>
|
||||||
|
{record.is_passed ? '已通过' : '未通过'}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="分数">
|
||||||
|
{record.status === 'in_progress' ? (
|
||||||
|
<span>-</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ fontSize: 18, fontWeight: 'bold', color: record.is_passed ? '#52c41a' : '#ff4d4f' }}>
|
||||||
|
{record.score} / {record.total_score}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="开始时间">
|
||||||
|
{record.start_time ? new Date(record.start_time).toLocaleString() : '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="提交时间">
|
||||||
|
{record.submit_time ? new Date(record.submit_time).toLocaleString() : '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="用时">
|
||||||
|
{record.time_spent ? `${Math.floor(record.time_spent / 60)} 分 ${record.time_spent % 60} 秒` : '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
<Divider style={{ margin: '12px 0' }} />
|
||||||
|
<Space>
|
||||||
|
{record.status === 'in_progress' && (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
icon={<PlayCircleOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
setRecordsDrawerVisible(false)
|
||||||
|
navigate(`/exam/${record.exam_id}/taking/${record.id}`)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
继续答题
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{record.status !== 'in_progress' && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<FileTextOutlined />}
|
||||||
|
onClick={() => handleViewRecordDetail(record.id)}
|
||||||
|
>
|
||||||
|
查看详情
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Spin>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
{/* 创建试卷模态框 */}
|
||||||
|
<Modal
|
||||||
|
title="创建试卷"
|
||||||
|
open={createModalVisible}
|
||||||
|
onCancel={() => {
|
||||||
|
setCreateModalVisible(false)
|
||||||
|
form.resetFields()
|
||||||
|
}}
|
||||||
|
footer={null}
|
||||||
|
width={500}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={handleCreateExam}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
label="试卷标题"
|
||||||
|
name="title"
|
||||||
|
rules={[{ required: true, message: '请输入试卷标题' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="例如:保密知识测试卷(一)" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" htmlType="submit" loading={loading}>
|
||||||
|
创建试卷
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => {
|
||||||
|
setCreateModalVisible(false)
|
||||||
|
form.resetFields()
|
||||||
|
}}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ExamManagement
|
||||||
221
web/src/pages/ExamResultNew.module.less
Normal file
221
web/src/pages/ExamResultNew.module.less
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
.loadingContainer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
:global(.ant-result) {
|
||||||
|
padding: 40px 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.statsCard {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
.passMark {
|
||||||
|
margin-top: 24px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeScoreCard {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
.typeScoreItem {
|
||||||
|
padding: 16px;
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f0f5ff;
|
||||||
|
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeScoreHeader {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeScoreContent {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeScoreProgress {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
background: #e8e8e8;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.typeScoreBar {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.6s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailCard {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
.panelHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.questionItem {
|
||||||
|
.questionNumber {
|
||||||
|
display: inline-block;
|
||||||
|
background: #1890ff;
|
||||||
|
color: white;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answerDetail {
|
||||||
|
.questionContent {
|
||||||
|
padding: 12px;
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answerSection {
|
||||||
|
padding-left: 12px;
|
||||||
|
|
||||||
|
.answerItem {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.correct {
|
||||||
|
color: #52c41a;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.incorrect {
|
||||||
|
color: #ff4d4f;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.aiGrading {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #f6ffed;
|
||||||
|
border: 1px solid #b7eb8f;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionsCard {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 响应式适配
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
padding: 12px;
|
||||||
|
|
||||||
|
:global(.ant-result) {
|
||||||
|
padding: 24px 16px;
|
||||||
|
|
||||||
|
:global(.ant-result-title) {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.statsCard {
|
||||||
|
:global(.ant-col) {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.ant-statistic) {
|
||||||
|
:global(.ant-statistic-title) {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.ant-statistic-content) {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeScoreCard {
|
||||||
|
.typeScoreItem {
|
||||||
|
padding: 12px;
|
||||||
|
|
||||||
|
.typeScoreHeader {
|
||||||
|
gap: 2px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
:global(.ant-typography) {
|
||||||
|
font-size: 14px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeScoreContent {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
:global(.ant-typography) {
|
||||||
|
&:first-child {
|
||||||
|
font-size: 22px !important;
|
||||||
|
}
|
||||||
|
&:last-child {
|
||||||
|
font-size: 14px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeScoreProgress {
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailCard {
|
||||||
|
.questionItem {
|
||||||
|
.answerSection {
|
||||||
|
padding-left: 0;
|
||||||
|
|
||||||
|
.answerItem {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionsCard {
|
||||||
|
:global(.ant-space) {
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
498
web/src/pages/ExamResultNew.tsx
Normal file
498
web/src/pages/ExamResultNew.tsx
Normal file
@ -0,0 +1,498 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Result,
|
||||||
|
Button,
|
||||||
|
Typography,
|
||||||
|
Tag,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
message,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
Statistic,
|
||||||
|
Divider
|
||||||
|
} from 'antd'
|
||||||
|
import {
|
||||||
|
CheckCircleOutlined,
|
||||||
|
CloseCircleOutlined,
|
||||||
|
TrophyOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
HomeOutlined
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import * as examApi from '../api/exam'
|
||||||
|
import type { ExamRecordResponse, ExamAnswer } from '../types/exam'
|
||||||
|
import type { Question } from '../types/question'
|
||||||
|
import styles from './ExamResultNew.module.less'
|
||||||
|
|
||||||
|
const { Text, Paragraph } = Typography
|
||||||
|
|
||||||
|
// 题型名称映射
|
||||||
|
const TYPE_NAME: Record<string, string> = {
|
||||||
|
'fill-in-blank': '填空题',
|
||||||
|
'true-false': '判断题',
|
||||||
|
'multiple-choice': '单选题',
|
||||||
|
'multiple-selection': '多选题',
|
||||||
|
'short-answer': '简答题',
|
||||||
|
'ordinary-essay': '论述题',
|
||||||
|
'management-essay': '论述题',
|
||||||
|
'essay': '论述题' // 合并后的论述题类型
|
||||||
|
}
|
||||||
|
|
||||||
|
// 题型顺序定义
|
||||||
|
const TYPE_ORDER: Record<string, number> = {
|
||||||
|
'fill-in-blank': 1,
|
||||||
|
'true-false': 2,
|
||||||
|
'multiple-choice': 3,
|
||||||
|
'multiple-selection': 4,
|
||||||
|
'short-answer': 5,
|
||||||
|
'ordinary-essay': 6,
|
||||||
|
'management-essay': 6,
|
||||||
|
'essay': 6 // 合并后的论述题顺序
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExamResultNew: React.FC = () => {
|
||||||
|
const { recordId } = useParams<{ recordId: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [data, setData] = useState<ExamRecordResponse | null>(null)
|
||||||
|
const [questions, setQuestions] = useState<Question[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!recordId) {
|
||||||
|
message.error('参数错误')
|
||||||
|
navigate('/exam/management')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loadResult()
|
||||||
|
}, [recordId])
|
||||||
|
|
||||||
|
const loadResult = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const recordRes = await examApi.getExamRecord(Number(recordId))
|
||||||
|
|
||||||
|
if (recordRes.success && recordRes.data) {
|
||||||
|
setData(recordRes.data)
|
||||||
|
|
||||||
|
// 获取试卷详情
|
||||||
|
if (recordRes.data.record.exam?.id) {
|
||||||
|
const examRes = await examApi.getExamDetail(recordRes.data.record.exam.id)
|
||||||
|
if (examRes.success && examRes.data) {
|
||||||
|
setQuestions(examRes.data.questions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
message.error('加载结果失败')
|
||||||
|
navigate('/exam/management')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.response?.data?.message || '加载结果失败')
|
||||||
|
navigate('/exam/management')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className={styles.loadingContainer}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const { record, answers } = data
|
||||||
|
const isPassed = record.is_passed
|
||||||
|
// 总分统一为100分
|
||||||
|
const scorePercent = record.score
|
||||||
|
|
||||||
|
// 构建答案映射
|
||||||
|
const answerMap = new Map<number, ExamAnswer>()
|
||||||
|
answers.forEach(ans => {
|
||||||
|
answerMap.set(ans.question_id, ans)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 统计正确率
|
||||||
|
const correctCount = answers.filter(a => a.is_correct).length
|
||||||
|
const totalCount = answers.length
|
||||||
|
const correctRate = totalCount > 0 ? (correctCount / totalCount) * 100 : 0
|
||||||
|
|
||||||
|
// 格式化时间
|
||||||
|
const formatTime = (seconds: number) => {
|
||||||
|
const totalSeconds = Math.floor(seconds) // 确保是整数
|
||||||
|
const minutes = Math.floor(totalSeconds / 60)
|
||||||
|
const secs = totalSeconds % 60
|
||||||
|
return `${minutes}分${secs}秒`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染答案详情
|
||||||
|
const renderAnswerDetail = (question: Question, answer: ExamAnswer) => {
|
||||||
|
const isCorrect = answer.is_correct
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.answerDetail}>
|
||||||
|
{/* 题目内容 - 填空题特殊处理 */}
|
||||||
|
<div className={styles.questionContent}>
|
||||||
|
{question.type === 'fill-in-blank' ? (
|
||||||
|
<Paragraph strong style={{ fontSize: 16, marginBottom: 16 }}>
|
||||||
|
{renderFillInBlankQuestion(question.content)}
|
||||||
|
</Paragraph>
|
||||||
|
) : (
|
||||||
|
<Paragraph strong style={{ fontSize: 16, marginBottom: 16 }}>
|
||||||
|
{question.content}
|
||||||
|
</Paragraph>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.answerSection}>
|
||||||
|
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||||
|
{/* 用户答案 */}
|
||||||
|
<div className={styles.answerItem}>
|
||||||
|
<Space>
|
||||||
|
<Text type="secondary">你的答案:</Text>
|
||||||
|
<Text strong className={isCorrect ? styles.correct : styles.incorrect}>
|
||||||
|
{formatAnswer(answer.answer, question.type)}
|
||||||
|
</Text>
|
||||||
|
{isCorrect ? (
|
||||||
|
<CheckCircleOutlined style={{ color: '#52c41a' }} />
|
||||||
|
) : (
|
||||||
|
<CloseCircleOutlined style={{ color: '#ff4d4f' }} />
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 正确答案 */}
|
||||||
|
<div className={styles.answerItem}>
|
||||||
|
<Space>
|
||||||
|
<Text type="secondary">正确答案:</Text>
|
||||||
|
<Text strong style={{ color: '#52c41a' }}>
|
||||||
|
{formatAnswer(answer.correct_answer, question.type)}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 得分 */}
|
||||||
|
<div className={styles.answerItem}>
|
||||||
|
<Space>
|
||||||
|
<Text type="secondary">得分:</Text>
|
||||||
|
<Text strong style={{ color: isCorrect ? '#52c41a' : '#ff4d4f', fontSize: 16 }}>
|
||||||
|
{answer.score.toFixed(1)} 分
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI评分详情 */}
|
||||||
|
{answer.ai_grading && (
|
||||||
|
<div className={styles.aiGrading} style={{ marginTop: 12, padding: 16, background: '#f0f5ff', borderRadius: 8 }}>
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<Text strong style={{ color: '#1890ff' }}>AI评分详情:</Text>
|
||||||
|
</div>
|
||||||
|
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||||
|
<div>
|
||||||
|
<Text type="secondary">AI得分:</Text>
|
||||||
|
<Text strong>{answer.ai_grading.score} / 100</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text type="secondary">评语:</Text>
|
||||||
|
<Text>{answer.ai_grading.feedback}</Text>
|
||||||
|
</div>
|
||||||
|
{answer.ai_grading.suggestion && (
|
||||||
|
<div>
|
||||||
|
<Text type="secondary">改进建议:</Text>
|
||||||
|
<Text>{answer.ai_grading.suggestion}</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染填空题题目(将 **** 替换为下划线)
|
||||||
|
const renderFillInBlankQuestion = (content: string) => {
|
||||||
|
const parts = content.split('****')
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
{parts.map((part, i) => (
|
||||||
|
<React.Fragment key={i}>
|
||||||
|
{part}
|
||||||
|
{i < parts.length - 1 && (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
minWidth: '120px',
|
||||||
|
borderBottom: '2px solid #1890ff',
|
||||||
|
marginLeft: 8,
|
||||||
|
marginRight: 8
|
||||||
|
}}>
|
||||||
|
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化用户答案
|
||||||
|
const formatAnswer = (answer: any, type: string): string => {
|
||||||
|
if (answer === null || answer === undefined || answer === '') {
|
||||||
|
return '未作答'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(answer)) {
|
||||||
|
if (answer.length === 0) return '未作答'
|
||||||
|
return answer.filter(a => a !== null && a !== undefined && a !== '').join('、')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'true-false') {
|
||||||
|
// 处理判断题:支持字符串和布尔值
|
||||||
|
const answerStr = String(answer).toLowerCase()
|
||||||
|
return answerStr === 'true' ? '正确' : '错误'
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(answer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按题型分组(合并两种论述题)
|
||||||
|
const groupedQuestions = questions.reduce((acc, q) => {
|
||||||
|
// 将两种论述题统一为 'essay'
|
||||||
|
const displayType = (q.type === 'ordinary-essay' || q.type === 'management-essay') ? 'essay' : q.type
|
||||||
|
if (!acc[displayType]) {
|
||||||
|
acc[displayType] = []
|
||||||
|
}
|
||||||
|
acc[displayType].push(q)
|
||||||
|
return acc
|
||||||
|
}, {} as Record<string, Question[]>)
|
||||||
|
|
||||||
|
// 计算各题型得分(已在 groupedQuestions 中合并论述题)
|
||||||
|
const typeScores = Object.entries(groupedQuestions)
|
||||||
|
.map(([type, qs]) => {
|
||||||
|
const typeAnswers = qs.map(q => answerMap.get(q.id)).filter(Boolean) as ExamAnswer[]
|
||||||
|
const totalScore = typeAnswers.reduce((sum, ans) => sum + ans.score, 0)
|
||||||
|
const maxScore = typeAnswers.length * (
|
||||||
|
type === 'fill-in-blank' ? 2.0 :
|
||||||
|
type === 'true-false' ? 2.0 :
|
||||||
|
type === 'multiple-choice' ? 1.0 :
|
||||||
|
type === 'multiple-selection' ? 2.5 :
|
||||||
|
type === 'short-answer' ? 10.0 :
|
||||||
|
(type === 'essay' || type === 'ordinary-essay' || type === 'management-essay') ? 5.0 : 0
|
||||||
|
)
|
||||||
|
const correctCount = typeAnswers.filter(ans => ans.is_correct).length
|
||||||
|
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
typeName: TYPE_NAME[type] || type,
|
||||||
|
totalScore,
|
||||||
|
maxScore,
|
||||||
|
correctCount,
|
||||||
|
totalCount: typeAnswers.length,
|
||||||
|
order: TYPE_ORDER[type] || 999
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.order - b.order)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{/* 成绩展示 */}
|
||||||
|
<Result
|
||||||
|
status={isPassed ? 'success' : 'warning'}
|
||||||
|
title={isPassed ? '恭喜你,考试通过!' : '很遗憾,未通过考试'}
|
||||||
|
subTitle={
|
||||||
|
<Space direction="vertical" size="large">
|
||||||
|
<Text style={{ fontSize: 16 }}>
|
||||||
|
{record.exam?.title || '模拟考试'}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 成绩统计 */}
|
||||||
|
<Card className={styles.statsCard}>
|
||||||
|
<Row gutter={[32, 16]}>
|
||||||
|
<Col xs={24} sm={12} md={6}>
|
||||||
|
<Statistic
|
||||||
|
title="总分"
|
||||||
|
value={scorePercent.toFixed(1)}
|
||||||
|
suffix="/ 100"
|
||||||
|
prefix={<TrophyOutlined />}
|
||||||
|
valueStyle={{ color: isPassed ? '#52c41a' : '#ff4d4f', fontSize: 32 }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={6}>
|
||||||
|
<Statistic
|
||||||
|
title="正确率"
|
||||||
|
value={correctRate.toFixed(1)}
|
||||||
|
suffix="%"
|
||||||
|
prefix={<CheckCircleOutlined />}
|
||||||
|
valueStyle={{ color: '#1890ff', fontSize: 32 }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={6}>
|
||||||
|
<Statistic
|
||||||
|
title="用时"
|
||||||
|
value={formatTime(record.time_spent)}
|
||||||
|
prefix={<ClockCircleOutlined />}
|
||||||
|
valueStyle={{ fontSize: 32 }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={6}>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ color: 'rgba(0, 0, 0, 0.45)', fontSize: 14, marginBottom: 8 }}>
|
||||||
|
考试状态
|
||||||
|
</div>
|
||||||
|
<Tag color={isPassed ? 'success' : 'error'} style={{ fontSize: 16, padding: '4px 16px' }}>
|
||||||
|
{isPassed ? '已通过' : '未通过'}
|
||||||
|
</Tag>
|
||||||
|
<div style={{ marginTop: 8, color: 'rgba(0, 0, 0, 0.45)', fontSize: 12 }}>
|
||||||
|
及格分数:{record.exam?.pass_score || 60} 分
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 各题型得分情况 */}
|
||||||
|
<Card
|
||||||
|
title={<Text strong style={{ fontSize: 18 }}>题型得分统计</Text>}
|
||||||
|
className={styles.typeScoreCard}
|
||||||
|
>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{typeScores.map(ts => (
|
||||||
|
<Col xs={24} sm={12} md={8} lg={6} key={ts.type}>
|
||||||
|
<div className={styles.typeScoreItem}>
|
||||||
|
<div className={styles.typeScoreHeader}>
|
||||||
|
<Text strong style={{ fontSize: 16 }}>{ts.typeName}</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
({ts.correctCount}/{ts.totalCount}题正确)
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className={styles.typeScoreContent}>
|
||||||
|
<Text
|
||||||
|
strong
|
||||||
|
style={{
|
||||||
|
fontSize: 28,
|
||||||
|
color: ts.totalScore === ts.maxScore ? '#52c41a' : '#1890ff'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ts.totalScore.toFixed(1)}
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 16, marginLeft: 4 }}>
|
||||||
|
/ {ts.maxScore.toFixed(1)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className={styles.typeScoreProgress}>
|
||||||
|
<div
|
||||||
|
className={styles.typeScoreBar}
|
||||||
|
style={{
|
||||||
|
width: `${(ts.totalScore / ts.maxScore) * 100}%`,
|
||||||
|
background: ts.totalScore === ts.maxScore ? '#52c41a' : '#1890ff'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 答题详情 - 直接展示,不使用折叠 */}
|
||||||
|
<Card title={<Text strong style={{ fontSize: 18 }}>答题详情</Text>} className={styles.detailCard}>
|
||||||
|
{Object.entries(groupedQuestions)
|
||||||
|
.sort(([typeA], [typeB]) => {
|
||||||
|
const orderA = TYPE_ORDER[typeA] || 999
|
||||||
|
const orderB = TYPE_ORDER[typeB] || 999
|
||||||
|
return orderA - orderB
|
||||||
|
})
|
||||||
|
.map(([type, qs]) => (
|
||||||
|
<div key={type} style={{ marginBottom: 32 }}>
|
||||||
|
{/* 题型标题 */}
|
||||||
|
<div style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
background: '#fafafa',
|
||||||
|
borderLeft: '4px solid #1890ff',
|
||||||
|
marginBottom: 16
|
||||||
|
}}>
|
||||||
|
<Space>
|
||||||
|
<Text strong style={{ fontSize: 16 }}>{TYPE_NAME[type] || type}</Text>
|
||||||
|
<Text type="secondary">(共 {qs.length} 题)</Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 题目列表 */}
|
||||||
|
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||||
|
{qs.map((q, idx) => {
|
||||||
|
const ans = answerMap.get(q.id)
|
||||||
|
if (!ans) return null
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={q.id}
|
||||||
|
size="small"
|
||||||
|
className={styles.questionCard}
|
||||||
|
style={{
|
||||||
|
borderLeft: ans.is_correct ? '4px solid #52c41a' : '4px solid #ff4d4f',
|
||||||
|
background: ans.is_correct ? '#f6ffed' : '#fff2f0'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<Tag color="blue">第 {idx + 1} 题</Tag>
|
||||||
|
</div>
|
||||||
|
{renderAnswerDetail(q, ans)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{/* 题型之间的分隔线 */}
|
||||||
|
<Divider />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<Card className={styles.actionsCard}>
|
||||||
|
<Space size="large">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
icon={<HomeOutlined />}
|
||||||
|
onClick={() => navigate('/exam/management')}
|
||||||
|
>
|
||||||
|
返回试卷列表
|
||||||
|
</Button>
|
||||||
|
{record.exam?.id && (
|
||||||
|
<Button
|
||||||
|
size="large"
|
||||||
|
icon={<FileTextOutlined />}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const res = await examApi.startExam(record.exam!.id)
|
||||||
|
if (res.success && res.data) {
|
||||||
|
navigate(`/exam/${record.exam!.id}/taking/${res.data.record_id}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('开始考试失败')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
再考一次
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ExamResultNew
|
||||||
400
web/src/pages/ExamTaking.module.less
Normal file
400
web/src/pages/ExamTaking.module.less
Normal file
@ -0,0 +1,400 @@
|
|||||||
|
.loadingContainer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: #f5f5f5;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
|
||||||
|
:global(.ant-card-body) {
|
||||||
|
padding: 20px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerContent {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
color: #1f2937;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
> div:first-child {
|
||||||
|
:global(.ant-typography) {
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
|
||||||
|
:global(.ant-statistic-title) {
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.ant-statistic-content) {
|
||||||
|
color: #1f2937;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.anticon) {
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.ant-divider-vertical) {
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statCard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 120px;
|
||||||
|
|
||||||
|
.statLabel {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statValue {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
:global(.ant-typography) {
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressInfo {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
|
||||||
|
:global(.ant-typography) {
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
|
||||||
|
:global(.ant-card-body) {
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.questionContainer {
|
||||||
|
min-height: 400px;
|
||||||
|
padding: 24px 0;
|
||||||
|
|
||||||
|
.questionHeader {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
:global(.ant-tag) {
|
||||||
|
padding: 4px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.ant-form-item-label > label) {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1f2937;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.ant-input),
|
||||||
|
:global(.ant-input-textarea) {
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #40a9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: #40a9ff;
|
||||||
|
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.ant-radio-wrapper),
|
||||||
|
:global(.ant-checkbox-wrapper) {
|
||||||
|
font-size: 15px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.navigation {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 24px 0;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.questionGroup {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
|
||||||
|
.groupHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global {
|
||||||
|
.ant-form-item-label > label {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(0, 0, 0, 0.85);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32px 0;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 抽屉样式
|
||||||
|
.drawerContent {
|
||||||
|
.questionTypeSection {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.typeHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
:global(.ant-typography) {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.questionGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(10, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 0;
|
||||||
|
|
||||||
|
.questionItem {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 13px;
|
||||||
|
background: #fff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #1890ff;
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.current {
|
||||||
|
border-color: #1890ff;
|
||||||
|
background: #1890ff;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.answered:not(.current) {
|
||||||
|
border-color: #52c41a;
|
||||||
|
background: #f6ffed;
|
||||||
|
color: #52c41a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 0 0 0;
|
||||||
|
margin-top: 12px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
|
||||||
|
.legendItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.legendBox {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fff;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&.current {
|
||||||
|
border-color: #1890ff;
|
||||||
|
background: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.answered {
|
||||||
|
border-color: #52c41a;
|
||||||
|
background: #f6ffed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.unanswered {
|
||||||
|
border-color: #d9d9d9;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.ant-typography) {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 响应式适配
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
.headerContent {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
:global(.ant-divider) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.ant-statistic) {
|
||||||
|
:global(.ant-statistic-title) {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.ant-statistic-content) {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressInfo {
|
||||||
|
:global(.ant-typography) {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
.questionContainer {
|
||||||
|
min-height: 300px;
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navigation {
|
||||||
|
padding: 16px 0;
|
||||||
|
|
||||||
|
:global(.ant-space) {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.questionGroup {
|
||||||
|
.groupHeader {
|
||||||
|
h4 {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global {
|
||||||
|
.ant-form-item-label > label {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
padding: 20px 0;
|
||||||
|
|
||||||
|
:global(.ant-space) {
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 抽屉响应式
|
||||||
|
.drawerContent {
|
||||||
|
.questionGrid {
|
||||||
|
grid-template-columns: repeat(10, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.questionItem {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
728
web/src/pages/ExamTaking.tsx
Normal file
728
web/src/pages/ExamTaking.tsx
Normal file
@ -0,0 +1,728 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Radio,
|
||||||
|
Checkbox,
|
||||||
|
Button,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
Spin,
|
||||||
|
Space,
|
||||||
|
Divider,
|
||||||
|
Modal,
|
||||||
|
Statistic,
|
||||||
|
FloatButton,
|
||||||
|
Drawer,
|
||||||
|
Tag
|
||||||
|
} from 'antd'
|
||||||
|
import {
|
||||||
|
ArrowLeftOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
ExclamationCircleOutlined,
|
||||||
|
LeftOutlined,
|
||||||
|
RightOutlined,
|
||||||
|
UnorderedListOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import * as examApi from '../api/exam'
|
||||||
|
import type { Question } from '../types/question'
|
||||||
|
import type { ExamDetailResponse } from '../types/exam'
|
||||||
|
import styles from './ExamTaking.module.less'
|
||||||
|
|
||||||
|
const { Title, Text } = Typography
|
||||||
|
const { TextArea } = Input
|
||||||
|
const { Countdown } = Statistic
|
||||||
|
|
||||||
|
// 题型名称映射
|
||||||
|
const TYPE_NAME: Record<string, string> = {
|
||||||
|
'fill-in-blank': '填空题',
|
||||||
|
'true-false': '判断题',
|
||||||
|
'multiple-choice': '单选题',
|
||||||
|
'multiple-selection': '多选题',
|
||||||
|
'short-answer': '简答题',
|
||||||
|
'ordinary-essay': '论述题',
|
||||||
|
'management-essay': '论述题'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 题型顺序定义
|
||||||
|
const TYPE_ORDER: Record<string, number> = {
|
||||||
|
'fill-in-blank': 1,
|
||||||
|
'true-false': 2,
|
||||||
|
'multiple-choice': 3,
|
||||||
|
'multiple-selection': 4,
|
||||||
|
'short-answer': 5,
|
||||||
|
'ordinary-essay': 6,
|
||||||
|
'management-essay': 6
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExamTaking: React.FC = () => {
|
||||||
|
const { examId, recordId } = useParams<{ examId: string; recordId: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [examData, setExamData] = useState<ExamDetailResponse | null>(null)
|
||||||
|
const [groupedQuestions, setGroupedQuestions] = useState<Record<string, Question[]>>({})
|
||||||
|
const [answeredCount, setAnsweredCount] = useState(0)
|
||||||
|
const [endTime, setEndTime] = useState<number>(0)
|
||||||
|
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0)
|
||||||
|
const [drawerVisible, setDrawerVisible] = useState(false)
|
||||||
|
|
||||||
|
// 加载考试详情
|
||||||
|
useEffect(() => {
|
||||||
|
if (!examId || !recordId) {
|
||||||
|
message.error('参数错误')
|
||||||
|
navigate('/exam/management')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadExam = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await examApi.getExamDetail(Number(examId))
|
||||||
|
if (res.success && res.data) {
|
||||||
|
// 获取用户信息,判断用户类型
|
||||||
|
const userStr = localStorage.getItem('user')
|
||||||
|
const user = userStr ? JSON.parse(userStr) : null
|
||||||
|
const userType = user?.user_type // 'ordinary-person' 或 'management-person'
|
||||||
|
|
||||||
|
// 过滤题目:根据用户类型只保留对应的论述题
|
||||||
|
let filteredQuestions = res.data.questions.filter(q => {
|
||||||
|
if (q.type === 'ordinary-essay') {
|
||||||
|
return userType === 'ordinary-person'
|
||||||
|
}
|
||||||
|
if (q.type === 'management-essay') {
|
||||||
|
return userType === 'management-person'
|
||||||
|
}
|
||||||
|
return true // 其他题目全部保留
|
||||||
|
})
|
||||||
|
|
||||||
|
// 按照题型顺序排序题目
|
||||||
|
filteredQuestions.sort((a, b) => {
|
||||||
|
const orderA = TYPE_ORDER[a.type] || 999
|
||||||
|
const orderB = TYPE_ORDER[b.type] || 999
|
||||||
|
return orderA - orderB
|
||||||
|
})
|
||||||
|
|
||||||
|
setExamData({
|
||||||
|
...res.data,
|
||||||
|
questions: filteredQuestions
|
||||||
|
})
|
||||||
|
|
||||||
|
// 按题型分组
|
||||||
|
const grouped = groupQuestionsByType(filteredQuestions)
|
||||||
|
setGroupedQuestions(grouped)
|
||||||
|
|
||||||
|
// 检查是否有保存的剩余时间(暂停状态)
|
||||||
|
const savedProgress = localStorage.getItem(`exam_progress_${recordId}`)
|
||||||
|
if (savedProgress) {
|
||||||
|
try {
|
||||||
|
const progress = JSON.parse(savedProgress)
|
||||||
|
if (progress.remainingTime) {
|
||||||
|
// 恢复暂停时的剩余时间
|
||||||
|
setEndTime(Date.now() + progress.remainingTime)
|
||||||
|
} else {
|
||||||
|
// 没有暂停记录,使用完整考试时长
|
||||||
|
const duration = res.data.exam.duration * 60 * 1000
|
||||||
|
setEndTime(Date.now() + duration)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('解析进度失败', e)
|
||||||
|
const duration = res.data.exam.duration * 60 * 1000
|
||||||
|
setEndTime(Date.now() + duration)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 首次进入,使用完整考试时长
|
||||||
|
const duration = res.data.exam.duration * 60 * 1000
|
||||||
|
setEndTime(Date.now() + duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复答题进度(先从服务器,再从localStorage)
|
||||||
|
await loadProgressFromServer()
|
||||||
|
} else {
|
||||||
|
message.error('加载考试失败')
|
||||||
|
navigate('/exam/management')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.response?.data?.message || '加载考试失败')
|
||||||
|
navigate('/exam/management')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadExam()
|
||||||
|
}, [examId, recordId, navigate])
|
||||||
|
|
||||||
|
// 定时保存进度(每30秒)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!recordId) return
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
saveCurrentQuestionToServer()
|
||||||
|
}, 30000) // 30秒
|
||||||
|
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [recordId, currentQuestionIndex, examData]) // 依赖当前题目索引
|
||||||
|
|
||||||
|
// 按题型分组题目
|
||||||
|
const groupQuestionsByType = (questions: Question[]) => {
|
||||||
|
const grouped: Record<string, Question[]> = {}
|
||||||
|
questions.forEach((q) => {
|
||||||
|
if (!grouped[q.type]) {
|
||||||
|
grouped[q.type] = []
|
||||||
|
}
|
||||||
|
grouped[q.type].push(q)
|
||||||
|
})
|
||||||
|
return grouped
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存当前题目答案到数据库
|
||||||
|
const saveCurrentQuestionToServer = async () => {
|
||||||
|
if (!recordId || !examData || currentQuestionIndex < 0) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentQuestion = examData.questions[currentQuestionIndex]
|
||||||
|
const fieldName = `q_${currentQuestion.id}`
|
||||||
|
const answer = form.getFieldValue(fieldName)
|
||||||
|
|
||||||
|
// 只有当答案不为空时才保存
|
||||||
|
if (answer !== undefined && answer !== null && answer !== '') {
|
||||||
|
await examApi.saveExamProgress(Number(recordId), {
|
||||||
|
question_id: currentQuestion.id,
|
||||||
|
answer: answer
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('保存答案失败:', error)
|
||||||
|
// 静默失败,不打扰用户
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存答题进度(仅localStorage,用于快速保存)
|
||||||
|
const saveProgress = () => {
|
||||||
|
if (!recordId) return
|
||||||
|
const values = form.getFieldsValue()
|
||||||
|
const remaining = endTime - Date.now()
|
||||||
|
const progress = {
|
||||||
|
answers: values,
|
||||||
|
remainingTime: remaining > 0 ? remaining : 0,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}
|
||||||
|
localStorage.setItem(`exam_progress_${recordId}`, JSON.stringify(progress))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从服务器恢复答题进度
|
||||||
|
const loadProgressFromServer = async () => {
|
||||||
|
if (!recordId) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 先尝试从服务器加载答案
|
||||||
|
const res = await examApi.getExamUserAnswers(Number(recordId))
|
||||||
|
if (res.success && res.data && Object.keys(res.data).length > 0) {
|
||||||
|
form.setFieldsValue(res.data)
|
||||||
|
updateAnsweredCount(res.data)
|
||||||
|
message.success('已恢复服务器保存的答题进度')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('从服务器恢复进度失败:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 如果服务器没有数据,尝试从localStorage恢复
|
||||||
|
const saved = localStorage.getItem(`exam_progress_${recordId}`)
|
||||||
|
if (saved) {
|
||||||
|
try {
|
||||||
|
const progress = JSON.parse(saved)
|
||||||
|
if (progress.answers) {
|
||||||
|
form.setFieldsValue(progress.answers)
|
||||||
|
updateAnsweredCount(progress.answers)
|
||||||
|
message.success('已恢复本地保存的答题进度')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('恢复本地进度失败', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新已答题目数量
|
||||||
|
const updateAnsweredCount = (values: any) => {
|
||||||
|
let count = 0
|
||||||
|
Object.values(values).forEach((val: any) => {
|
||||||
|
if (val !== undefined && val !== null && val !== '') {
|
||||||
|
if (Array.isArray(val) && val.length > 0) {
|
||||||
|
count++
|
||||||
|
} else if (!Array.isArray(val)) {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setAnsweredCount(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听表单变化
|
||||||
|
const handleFormChange = () => {
|
||||||
|
const values = form.getFieldsValue()
|
||||||
|
updateAnsweredCount(values)
|
||||||
|
saveProgress()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交考试
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const totalQuestions = examData?.questions.length || 0
|
||||||
|
const unanswered = totalQuestions - answeredCount
|
||||||
|
|
||||||
|
// 先保存当前题目答案
|
||||||
|
await saveCurrentQuestionToServer()
|
||||||
|
|
||||||
|
if (unanswered > 0) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认提交',
|
||||||
|
icon: <ExclamationCircleOutlined />,
|
||||||
|
content: `还有 ${unanswered} 道题未作答,确认提交吗?`,
|
||||||
|
okText: '确认提交',
|
||||||
|
cancelText: '继续答题',
|
||||||
|
onOk: () => submitExam()
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认提交',
|
||||||
|
icon: <CheckCircleOutlined />,
|
||||||
|
content: '已完成所有题目,确认提交吗?',
|
||||||
|
okText: '确认提交',
|
||||||
|
cancelText: '检查答案',
|
||||||
|
onOk: () => submitExam()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交答案并触发阅卷
|
||||||
|
const submitExam = async () => {
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
// 直接提交,后端会从数据库读取答案并阅卷
|
||||||
|
const res = await examApi.submitExamAnswer(Number(recordId), {})
|
||||||
|
if (res.success) {
|
||||||
|
message.success('提交成功,正在阅卷...')
|
||||||
|
// 清除进度
|
||||||
|
localStorage.removeItem(`exam_progress_${recordId}`)
|
||||||
|
// 跳转到试卷列表页面
|
||||||
|
navigate('/exam/management')
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '提交失败')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.response?.data?.message || '提交失败')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 时间到自动提交
|
||||||
|
const handleTimeFinish = async () => {
|
||||||
|
message.warning('考试时间已到,系统将自动提交')
|
||||||
|
await saveCurrentQuestionToServer() // 先保存当前答案
|
||||||
|
submitExam()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上一题
|
||||||
|
const handlePrevQuestion = async () => {
|
||||||
|
if (currentQuestionIndex > 0) {
|
||||||
|
await saveCurrentQuestionToServer() // 保存当前题目答案到服务器
|
||||||
|
setCurrentQuestionIndex(currentQuestionIndex - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下一题
|
||||||
|
const handleNextQuestion = async () => {
|
||||||
|
if (examData && currentQuestionIndex < examData.questions.length - 1) {
|
||||||
|
await saveCurrentQuestionToServer() // 保存当前题目答案到服务器
|
||||||
|
setCurrentQuestionIndex(currentQuestionIndex + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳转到指定题目
|
||||||
|
const handleJumpToQuestion = (index: number) => {
|
||||||
|
setCurrentQuestionIndex(index)
|
||||||
|
setDrawerVisible(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查题目是否已答
|
||||||
|
const isQuestionAnswered = (question: Question): boolean => {
|
||||||
|
const fieldName = `q_${question.id}`
|
||||||
|
const value = form.getFieldValue(fieldName)
|
||||||
|
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
// 处理多选题和填空题
|
||||||
|
return value.length > 0 && value.some(v => v !== undefined && v !== null && v !== '')
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染题目
|
||||||
|
const renderQuestion = (question: Question) => {
|
||||||
|
const fieldName = `q_${question.id}`
|
||||||
|
|
||||||
|
switch (question.type) {
|
||||||
|
case 'fill-in-blank':
|
||||||
|
// 将题目按 **** 分割,在占位符位置插入输入框
|
||||||
|
const parts = question.content.split('****')
|
||||||
|
const blankCount = parts.length - 1 // 填空数量 = 分割后的部分数 - 1
|
||||||
|
|
||||||
|
if (blankCount === 0) {
|
||||||
|
// 如果没有 ****,显示警告并提供一个文本框
|
||||||
|
return (
|
||||||
|
<Form.Item
|
||||||
|
key={question.id}
|
||||||
|
label={question.content}
|
||||||
|
required={false}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: 12, padding: '12px', background: '#fff7e6', border: '1px solid #ffd591', borderRadius: '4px' }}>
|
||||||
|
<Text type="warning" style={{ fontSize: 14 }}>
|
||||||
|
提示:题目格式错误,缺少 **** 占位符,请联系管理员修正。
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Form.Item
|
||||||
|
name={[fieldName, 0]}
|
||||||
|
rules={[{ required: true, message: '请填写答案' }]}
|
||||||
|
noStyle
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
placeholder="请在此处填写答案"
|
||||||
|
autoSize={{ minRows: 2, maxRows: 6 }}
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
borderBottom: '2px solid #1890ff',
|
||||||
|
borderRadius: 0,
|
||||||
|
padding: '4px 0',
|
||||||
|
boxShadow: 'none'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form.Item
|
||||||
|
key={question.id}
|
||||||
|
required={false}
|
||||||
|
style={{ marginBottom: 24 }}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 500, color: '#1f2937', lineHeight: 2, display: 'flex', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
{parts.map((part, i) => (
|
||||||
|
<React.Fragment key={i}>
|
||||||
|
<span>{part}</span>
|
||||||
|
{i < blankCount && (
|
||||||
|
<Form.Item
|
||||||
|
name={[fieldName, i]}
|
||||||
|
rules={[{ required: true, message: '请填写' }]}
|
||||||
|
style={{ display: 'inline-block', margin: '0 8px 0 8px' }}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder={`填空 ${i + 1}`}
|
||||||
|
style={{
|
||||||
|
width: 180,
|
||||||
|
border: 'none',
|
||||||
|
borderBottom: '2px solid #1890ff',
|
||||||
|
borderRadius: 0,
|
||||||
|
padding: '4px 0',
|
||||||
|
boxShadow: 'none'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'true-false':
|
||||||
|
return (
|
||||||
|
<Form.Item
|
||||||
|
key={question.id}
|
||||||
|
name={fieldName}
|
||||||
|
label={question.content}
|
||||||
|
rules={[{ required: true, message: '请选择答案' }]}
|
||||||
|
required={false}
|
||||||
|
>
|
||||||
|
<Radio.Group>
|
||||||
|
<Space direction="vertical">
|
||||||
|
<Radio value="true">正确</Radio>
|
||||||
|
<Radio value="false">错误</Radio>
|
||||||
|
</Space>
|
||||||
|
</Radio.Group>
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'multiple-choice':
|
||||||
|
return (
|
||||||
|
<Form.Item
|
||||||
|
key={question.id}
|
||||||
|
name={fieldName}
|
||||||
|
label={question.content}
|
||||||
|
rules={[{ required: true, message: '请选择答案' }]}
|
||||||
|
required={false}
|
||||||
|
>
|
||||||
|
<Radio.Group>
|
||||||
|
<Space direction="vertical">
|
||||||
|
{question.options?.map((opt) => (
|
||||||
|
<Radio key={opt.key} value={opt.key}>
|
||||||
|
{opt.key}. {opt.value}
|
||||||
|
</Radio>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Radio.Group>
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'multiple-selection':
|
||||||
|
return (
|
||||||
|
<Form.Item
|
||||||
|
key={question.id}
|
||||||
|
name={fieldName}
|
||||||
|
label={question.content}
|
||||||
|
rules={[{ required: true, message: '请选择答案' }]}
|
||||||
|
required={false}
|
||||||
|
>
|
||||||
|
<Checkbox.Group>
|
||||||
|
<Space direction="vertical">
|
||||||
|
{question.options?.map((opt) => (
|
||||||
|
<Checkbox key={opt.key} value={opt.key}>
|
||||||
|
{opt.key}. {opt.value}
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Checkbox.Group>
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'short-answer':
|
||||||
|
case 'ordinary-essay':
|
||||||
|
case 'management-essay':
|
||||||
|
return (
|
||||||
|
<Form.Item
|
||||||
|
key={question.id}
|
||||||
|
name={fieldName}
|
||||||
|
label={question.content}
|
||||||
|
rules={[{ required: true, message: '请作答' }]}
|
||||||
|
required={false}
|
||||||
|
>
|
||||||
|
<TextArea rows={6} placeholder="请输入你的答案" />
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className={styles.loadingContainer}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!examData) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalQuestions = examData.questions.length
|
||||||
|
const currentQuestion = examData.questions[currentQuestionIndex]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{/* 顶部信息栏 */}
|
||||||
|
<Card className={styles.header}>
|
||||||
|
<div className={styles.headerContent}>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
icon={<ArrowLeftOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认退出',
|
||||||
|
content: '退出将保存当前答题进度,确认退出吗?',
|
||||||
|
onOk: () => {
|
||||||
|
saveProgress()
|
||||||
|
navigate('/exam/management')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
style={{ marginBottom: 8 }}
|
||||||
|
>
|
||||||
|
退出考试
|
||||||
|
</Button>
|
||||||
|
<Title level={3}>{examData.exam.title}</Title>
|
||||||
|
<Text type="secondary">
|
||||||
|
题目 {currentQuestionIndex + 1}/{totalQuestions}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className={styles.stats}>
|
||||||
|
<div className={styles.statCard}>
|
||||||
|
<Text type="secondary" className={styles.statLabel}>剩余时间</Text>
|
||||||
|
<div className={styles.statValue}>
|
||||||
|
<ClockCircleOutlined style={{ color: '#1890ff', fontSize: 20 }} />
|
||||||
|
<Countdown
|
||||||
|
value={endTime}
|
||||||
|
format="mm:ss"
|
||||||
|
onFinish={handleTimeFinish}
|
||||||
|
style={{ fontSize: 24, fontWeight: 600, color: '#1f2937', lineHeight: 1 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Divider type="vertical" style={{ height: 60 }} />
|
||||||
|
<div className={styles.statCard}>
|
||||||
|
<Text type="secondary" className={styles.statLabel}>答题进度</Text>
|
||||||
|
<div className={styles.statValue}>
|
||||||
|
<CheckCircleOutlined style={{ color: '#1890ff', fontSize: 20 }} />
|
||||||
|
<Text strong style={{ fontSize: 24, lineHeight: 1 }}>
|
||||||
|
{answeredCount}/{totalQuestions}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 答题区域 - 单题显示 */}
|
||||||
|
<Card className={styles.content}>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
onValuesChange={handleFormChange}
|
||||||
|
>
|
||||||
|
{currentQuestion && (
|
||||||
|
<div className={styles.questionContainer}>
|
||||||
|
<div className={styles.questionHeader}>
|
||||||
|
<Text strong style={{ fontSize: 16, marginRight: 8 }}>
|
||||||
|
第{(() => {
|
||||||
|
const typeQuestions = groupedQuestions[currentQuestion.type] || []
|
||||||
|
const typeIndex = typeQuestions.findIndex(q => q.id === currentQuestion.id)
|
||||||
|
return typeIndex + 1
|
||||||
|
})()}题
|
||||||
|
</Text>
|
||||||
|
<Tag color="blue">{TYPE_NAME[currentQuestion.type] || currentQuestion.type}</Tag>
|
||||||
|
</div>
|
||||||
|
{renderQuestion(currentQuestion)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 导航按钮 */}
|
||||||
|
<div className={styles.navigation}>
|
||||||
|
<Space size="large">
|
||||||
|
<Button
|
||||||
|
icon={<LeftOutlined />}
|
||||||
|
onClick={handlePrevQuestion}
|
||||||
|
disabled={currentQuestionIndex === 0}
|
||||||
|
>
|
||||||
|
上一题
|
||||||
|
</Button>
|
||||||
|
<Text type="secondary">
|
||||||
|
{currentQuestionIndex + 1} / {totalQuestions}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
icon={<RightOutlined />}
|
||||||
|
onClick={handleNextQuestion}
|
||||||
|
disabled={currentQuestionIndex === totalQuestions - 1}
|
||||||
|
>
|
||||||
|
下一题
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<CheckCircleOutlined />}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
loading={submitting}
|
||||||
|
>
|
||||||
|
提交答卷
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 悬浮球 */}
|
||||||
|
<FloatButton
|
||||||
|
icon={<UnorderedListOutlined />}
|
||||||
|
type="primary"
|
||||||
|
style={{ right: 24, bottom: 24 }}
|
||||||
|
onClick={() => setDrawerVisible(true)}
|
||||||
|
badge={{ count: answeredCount, overflowCount: 999 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 答题情况抽屉 */}
|
||||||
|
<Drawer
|
||||||
|
title="答题情况"
|
||||||
|
placement="right"
|
||||||
|
width={500}
|
||||||
|
open={drawerVisible}
|
||||||
|
onClose={() => setDrawerVisible(false)}
|
||||||
|
>
|
||||||
|
<div className={styles.drawerContent}>
|
||||||
|
{/* 按题型分组显示 */}
|
||||||
|
{Object.entries(groupedQuestions).sort(([typeA], [typeB]) => {
|
||||||
|
const orderA = TYPE_ORDER[typeA] || 999
|
||||||
|
const orderB = TYPE_ORDER[typeB] || 999
|
||||||
|
return orderA - orderB
|
||||||
|
}).map(([type, questions]) => {
|
||||||
|
return (
|
||||||
|
<div key={type} className={styles.questionTypeSection}>
|
||||||
|
<div className={styles.typeHeader}>
|
||||||
|
<Text strong>{TYPE_NAME[type] || type}</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
共 {questions.length} 题
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className={styles.questionGrid}>
|
||||||
|
{questions.map((q, idx) => {
|
||||||
|
const globalIndex = examData.questions.findIndex(eq => eq.id === q.id)
|
||||||
|
const isAnswered = isQuestionAnswered(q)
|
||||||
|
const isCurrent = globalIndex === currentQuestionIndex
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={q.id}
|
||||||
|
className={`${styles.questionItem} ${isCurrent ? styles.current : ''} ${isAnswered ? styles.answered : ''}`}
|
||||||
|
onClick={() => handleJumpToQuestion(globalIndex)}
|
||||||
|
title={`第 ${idx + 1} 题`}
|
||||||
|
>
|
||||||
|
{idx + 1}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div className={styles.legend}>
|
||||||
|
<div className={styles.legendItem}>
|
||||||
|
<div className={`${styles.legendBox} ${styles.current}`}></div>
|
||||||
|
<Text>当前题目</Text>
|
||||||
|
</div>
|
||||||
|
<div className={styles.legendItem}>
|
||||||
|
<div className={`${styles.legendBox} ${styles.answered}`}></div>
|
||||||
|
<Text>已答题目</Text>
|
||||||
|
</div>
|
||||||
|
<div className={styles.legendItem}>
|
||||||
|
<div className={`${styles.legendBox} ${styles.unanswered}`}></div>
|
||||||
|
<Text>未答题目</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Drawer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ExamTaking
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
import 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>
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user