Compare commits

..

No commits in common. "f9a5e06df2c18aca0fcf76041a63f324c6dc62c2" and "4f95514af8dafe4dfa6007b3218daf8e46889fee" have entirely different histories.

20 changed files with 205 additions and 1223 deletions

View File

@ -1,38 +0,0 @@
package main
import (
"ankao/pkg/config"
"fmt"
"log"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func main() {
// 直接连接数据库,不使用 InitDB
cfg := config.GetDatabaseConfig()
dsn := cfg.GetDSN()
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
})
if err != nil {
log.Fatal("数据库连接失败:", err)
}
log.Println("开始清理 exam_shares 表...")
// 删除 exam_shares 表(如果存在)
if err := db.Exec("DROP TABLE IF EXISTS exam_shares CASCADE").Error; err != nil {
log.Fatal("删除 exam_shares 表失败:", err)
}
log.Println("✓ 已删除 exam_shares 表")
log.Println("\n清理完成现在可以重新运行主程序。")
fmt.Println("\n执行步骤:")
fmt.Println("1. go run main.go # 这会自动创建正确的表结构")
fmt.Println("2. 如果有旧的分享数据需要迁移,运行:")
fmt.Println(" go run cmd/migrate_exam_shares.go")
}

View File

@ -1,189 +0,0 @@
package main
import (
"ankao/internal/database"
"ankao/internal/models"
"fmt"
"log"
"time"
"gorm.io/gorm"
)
// OldExam 旧的试卷模型(用于迁移)
type OldExam struct {
ID uint `gorm:"primaryKey"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
UserID uint `gorm:"not null;index"`
Title string `gorm:"type:varchar(200);default:''"`
TotalScore int `gorm:"not null;default:100"`
Duration int `gorm:"not null;default:60"`
PassScore int `gorm:"not null;default:60"`
QuestionIDs []byte `gorm:"type:json"`
Status string `gorm:"type:varchar(20);not null;default:'active'"`
IsShared bool `gorm:"default:false"`
SharedByID *uint `gorm:"index"`
}
func (OldExam) TableName() string {
return "exams"
}
func main() {
// 初始化数据库
if err := database.InitDB(); err != nil {
log.Fatal("数据库初始化失败:", err)
}
db := database.GetDB()
log.Println("开始迁移试卷分享数据...")
// 1. 查找所有被分享的试卷副本
var sharedExams []OldExam
if err := db.Where("is_shared = ? AND shared_by_id IS NOT NULL", true).
Find(&sharedExams).Error; err != nil {
log.Fatal("查询分享试卷失败:", err)
}
log.Printf("找到 %d 份分享试卷副本", len(sharedExams))
if len(sharedExams) == 0 {
log.Println("没有需要迁移的数据,退出。")
return
}
// 2. 按 shared_by_id + question_ids 分组
type ShareGroup struct {
SharedByID uint
QuestionIDs string
Exams []OldExam
}
groupMap := make(map[string]*ShareGroup)
for _, exam := range sharedExams {
if exam.SharedByID == nil {
continue
}
key := fmt.Sprintf("%d_%s", *exam.SharedByID, string(exam.QuestionIDs))
if group, exists := groupMap[key]; exists {
group.Exams = append(group.Exams, exam)
} else {
groupMap[key] = &ShareGroup{
SharedByID: *exam.SharedByID,
QuestionIDs: string(exam.QuestionIDs),
Exams: []OldExam{exam},
}
}
}
log.Printf("分组后共 %d 组", len(groupMap))
successCount := 0
failCount := 0
// 3. 处理每个分组
for _, group := range groupMap {
// 查找原始试卷
var originalExam OldExam
if err := db.Where("user_id = ? AND question_ids = ? AND is_shared = ?",
group.SharedByID, group.QuestionIDs, false).
First(&originalExam).Error; err != nil {
log.Printf("未找到原始试卷: shared_by_id=%d, 跳过该组", group.SharedByID)
failCount += len(group.Exams)
continue
}
// 开始事务
tx := db.Begin()
migrationSuccess := true
// 4. 为每个分享副本创建关联记录
for _, sharedExam := range group.Exams {
// 创建分享记录
share := models.ExamShare{
ExamID: originalExam.ID,
SharedByID: group.SharedByID,
SharedToID: sharedExam.UserID,
SharedAt: sharedExam.CreatedAt,
}
if err := tx.Create(&share).Error; err != nil {
log.Printf("创建分享记录失败: %v", err)
tx.Rollback()
failCount += len(group.Exams)
migrationSuccess = false
break
}
// 5. 更新考试记录,将 exam_id 指向原始试卷
if err := tx.Model(&models.ExamRecord{}).
Where("exam_id = ?", sharedExam.ID).
Update("exam_id", originalExam.ID).Error; err != nil {
log.Printf("更新考试记录失败: %v", err)
tx.Rollback()
failCount += len(group.Exams)
migrationSuccess = false
break
}
// 6. 软删除分享副本
if err := tx.Delete(&sharedExam).Error; err != nil {
log.Printf("删除分享副本失败: %v", err)
tx.Rollback()
failCount += len(group.Exams)
migrationSuccess = false
break
}
if migrationSuccess {
successCount++
}
}
// 提交事务
if migrationSuccess {
if err := tx.Commit().Error; err != nil {
log.Printf("提交事务失败: %v", err)
failCount += len(group.Exams)
}
}
}
log.Printf("迁移完成: 成功 %d, 失败 %d", successCount, failCount)
// 7. 验证迁移结果
log.Println("\n开始验证迁移结果...")
// 统计 exam_shares 表记录数
var shareCount int64
db.Model(&models.ExamShare{}).Count(&shareCount)
log.Printf("exam_shares 表记录数: %d", shareCount)
// 统计剩余的分享副本数应该为0
var remainingSharedExams int64
db.Model(&OldExam{}).Where("is_shared = ?", true).Count(&remainingSharedExams)
log.Printf("剩余分享副本数: %d (应该为0)", remainingSharedExams)
// 检查是否有孤立的考试记录
var orphanRecords int64
db.Raw(`
SELECT COUNT(*) FROM exam_records er
WHERE NOT EXISTS (
SELECT 1 FROM exams e WHERE e.id = er.exam_id AND e.deleted_at IS NULL
)
`).Scan(&orphanRecords)
log.Printf("孤立的考试记录数: %d (应该为0)", orphanRecords)
if remainingSharedExams == 0 && orphanRecords == 0 {
log.Println("\n✓ 迁移验证通过!")
} else {
log.Println("\n✗ 迁移验证失败,请检查数据!")
}
log.Println("\n注意: 如果验证通过可以考虑在未来某个时间点执行以下SQL移除旧字段:")
log.Println(" ALTER TABLE exams DROP COLUMN is_shared;")
log.Println(" ALTER TABLE exams DROP COLUMN shared_by_id;")
}

1
go.mod
View File

@ -41,7 +41,6 @@ require (
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.55.0 // indirect github.com/quic-go/quic-go v0.55.0 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/sashabaranov/go-openai v1.41.2 // indirect github.com/sashabaranov/go-openai v1.41.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect

2
go.sum
View File

@ -74,8 +74,6 @@ github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk= github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U= github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/sashabaranov/go-openai v1.41.2 h1:vfPRBZNMpnqu8ELsclWcAvF19lDNgh1t6TVfFFOPiSM= github.com/sashabaranov/go-openai v1.41.2 h1:vfPRBZNMpnqu8ELsclWcAvF19lDNgh1t6TVfFFOPiSM=
github.com/sashabaranov/go-openai v1.41.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= github.com/sashabaranov/go-openai v1.41.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=

View File

@ -38,7 +38,6 @@ func InitDB() error {
&models.WrongQuestionHistory{}, // 错题历史表 &models.WrongQuestionHistory{}, // 错题历史表
&models.UserAnswerRecord{}, // 用户答题记录表 &models.UserAnswerRecord{}, // 用户答题记录表
&models.Exam{}, // 考试表(试卷) &models.Exam{}, // 考试表(试卷)
&models.ExamShare{}, // 试卷分享关联表
&models.ExamRecord{}, // 考试记录表 &models.ExamRecord{}, // 考试记录表
&models.ExamUserAnswer{}, // 用户答案表 &models.ExamUserAnswer{}, // 用户答案表
) )

View File

@ -68,13 +68,13 @@ func gradeExam(recordID uint, examID uint, userID uint) {
// 使用固定的题型分值映射 // 使用固定的题型分值映射
scoreMap := map[string]float64{ scoreMap := map[string]float64{
"fill-in-blank": 2.0, // 填空题每题2分 "fill-in-blank": 2.0,
"true-false": 1.0, // 判断题每题1分 "true-false": 2.0,
"multiple-choice": 1.0, // 单选题每题1分 "multiple-choice": 1.0,
"multiple-selection": 2.0, // 多选题每题2分 "multiple-selection": 2.5,
"short-answer": 10.0, // 简答题10 "short-answer": 8.0, // 简答题 8
"ordinary-essay": 10.0, // 论述题10 "ordinary-essay": 9.0, // 论述题 9
"management-essay": 10.0, // 论述题10 "management-essay": 9.0, // 论述题 9
} }
// 评分 // 评分

View File

@ -46,13 +46,13 @@ func CreateExam(c *gin.Context) {
// 使用固定的题型配置总分100分 // 使用固定的题型配置总分100分
questionTypes := []models.QuestionTypeConfig{ questionTypes := []models.QuestionTypeConfig{
{Type: "fill-in-blank", Count: 20, Score: 2.0}, // 40分 {Type: "fill-in-blank", Count: 10, Score: 2.0}, // 20分
{Type: "true-false", Count: 10, Score: 1.0}, // 10分 {Type: "true-false", Count: 10, Score: 2.0}, // 20分
{Type: "multiple-choice", Count: 10, Score: 1.0}, // 10分 {Type: "multiple-choice", Count: 10, Score: 1.0}, // 10分
{Type: "multiple-selection", Count: 10, Score: 2.0}, // 20 {Type: "multiple-selection", Count: 10, Score: 2.5}, // 25
{Type: "short-answer", Count: 1, Score: 10.0}, // 10分 {Type: "short-answer", Count: 2, Score: 10.0}, // 20分
{Type: "ordinary-essay", Count: 1, Score: 10.0}, // 10分(普通涉密人员论述题) {Type: "ordinary-essay", Count: 1, Score: 4.5}, // 4.5分(普通涉密人员论述题)
{Type: "management-essay", Count: 1, Score: 10.0}, // 10分(保密管理人员论述题) {Type: "management-essay", Count: 1, Score: 4.5}, // 4.5分(保密管理人员论述题)
} }
// 按题型配置随机抽取题目 // 按题型配置随机抽取题目
@ -152,67 +152,44 @@ func GetExamList(c *gin.Context) {
db := database.GetDB() db := database.GetDB()
// 获取用户可访问的试卷(拥有的 + 被分享的) // 查询用户创建的试卷(包括被分享的试卷)
exams, err := models.GetAccessibleExams(userID.(int64), db) var exams []models.Exam
if err != nil { if err := db.Where("user_id = ? AND status = ?", userID, "active").
Preload("SharedBy", func(db *gorm.DB) *gorm.DB {
return db.Select("id", "username", "nickname")
}).
Order("created_at DESC").
Find(&exams).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
} }
// 为每个试卷添加统计信息 // 为每个试卷计算题目数量和获取考试记录统计
type ExamWithStats struct { type ExamWithStats struct {
models.Exam models.Exam
QuestionCount int `json:"question_count"` QuestionCount int `json:"question_count"`
AttemptCount int `json:"attempt_count"` // 考试次数(当前用户) AttemptCount int `json:"attempt_count"` // 考试次数
BestScore float64 `json:"best_score"` // 最高分(当前用户) BestScore float64 `json:"best_score"` // 最高分
HasInProgressExam bool `json:"has_in_progress_exam"` // 是否有进行中的考试 HasInProgressExam bool `json:"has_in_progress_exam"` // 是否有进行中的考试
InProgressRecordID uint `json:"in_progress_record_id,omitempty"` // 进行中的考试记录ID InProgressRecordID uint `json:"in_progress_record_id,omitempty"` // 进行中的考试记录ID
ParticipantCount int `json:"participant_count"` // 共享试卷的参与人数(所有用户)
IsShared bool `json:"is_shared"` // 是否为分享的试卷
SharedBy *struct {
ID int64 `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
} `json:"shared_by,omitempty"`
} }
result := make([]ExamWithStats, 0, len(exams)) result := make([]ExamWithStats, 0, len(exams))
for _, exam := range exams { for _, exam := range exams {
var questionIDs []uint var questionIDs []uint
if err := json.Unmarshal(exam.QuestionIDs, &questionIDs); err != nil { if err := json.Unmarshal(exam.QuestionIDs, &questionIDs); err == nil {
continue
}
stats := ExamWithStats{ stats := ExamWithStats{
Exam: exam, Exam: exam,
QuestionCount: len(questionIDs), QuestionCount: len(questionIDs),
} }
// 检查是否为分享试卷 // 查询该试卷的考试记录统计
if exam.UserID != uint(userID.(int64)) {
stats.IsShared = true
// 查询分享人信息
var share models.ExamShare
if err := db.Where("exam_id = ? AND shared_to_id = ?", exam.ID, userID.(int64)).
Preload("SharedBy").First(&share).Error; err == nil && share.SharedBy != nil {
stats.SharedBy = &struct {
ID int64 `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
}{
ID: share.SharedBy.ID,
Username: share.SharedBy.Username,
Nickname: share.SharedBy.Nickname,
}
}
}
// 当前用户的统计
var count int64 var count int64
db.Model(&models.ExamRecord{}).Where("exam_id = ? AND user_id = ?", exam.ID, userID).Count(&count) db.Model(&models.ExamRecord{}).Where("exam_id = ? AND user_id = ?", exam.ID, userID).Count(&count)
stats.AttemptCount = int(count) stats.AttemptCount = int(count)
// 查询最高分
var record models.ExamRecord var record models.ExamRecord
if err := db.Where("exam_id = ? AND user_id = ?", exam.ID, userID). if err := db.Where("exam_id = ? AND user_id = ?", exam.ID, userID).
Order("score DESC"). Order("score DESC").
@ -220,6 +197,7 @@ func GetExamList(c *gin.Context) {
stats.BestScore = record.Score stats.BestScore = record.Score
} }
// 查询是否有进行中的考试status为in_progress
var inProgressRecord models.ExamRecord var inProgressRecord models.ExamRecord
if err := db.Where("exam_id = ? AND user_id = ? AND status = ?", exam.ID, userID, "in_progress"). if err := db.Where("exam_id = ? AND user_id = ? AND status = ?", exam.ID, userID, "in_progress").
Order("created_at DESC"). Order("created_at DESC").
@ -228,16 +206,9 @@ func GetExamList(c *gin.Context) {
stats.InProgressRecordID = inProgressRecord.ID stats.InProgressRecordID = inProgressRecord.ID
} }
// 参与人数统计(简化:直接统计该试卷的不同用户)
var participantCount int64
db.Model(&models.ExamRecord{}).
Where("exam_id = ? AND status = ?", exam.ID, "graded").
Distinct("user_id").
Count(&participantCount)
stats.ParticipantCount = int(participantCount)
result = append(result, stats) result = append(result, stats)
} }
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
@ -508,32 +479,15 @@ func GetExamRecord(c *gin.Context) {
db := database.GetDB() db := database.GetDB()
// 查询考试记录(不限制用户,因为可能是查看共享试卷的其他用户记录) // 查询考试记录
var record models.ExamRecord var record models.ExamRecord
if err := db.Where("id = ?", recordID). if err := db.Where("id = ? AND user_id = ?", recordID, userID).
Preload("Exam"). Preload("Exam").
First(&record).Error; err != nil { 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
} }
// 检查权限:只有以下情况可以查看
// 1. 记录属于当前用户
// 2. 当前用户有权限访问该试卷
if record.UserID != uint(userID.(int64)) {
// 不是自己的记录,检查是否有权限访问该试卷
var exam models.Exam
if err := db.Where("id = ?", record.ExamID).First(&exam).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "试卷不存在"})
return
}
if !exam.IsAccessibleBy(userID.(int64), db) {
c.JSON(http.StatusForbidden, gin.H{"success": false, "message": "无权限查看此考试记录"})
return
}
}
// 从 exam_user_answers 表读取所有答案 // 从 exam_user_answers 表读取所有答案
var userAnswers []models.ExamUserAnswer var userAnswers []models.ExamUserAnswer
if err := db.Where("exam_record_id = ?", recordID).Find(&userAnswers).Error; err != nil { if err := db.Where("exam_record_id = ?", recordID).Find(&userAnswers).Error; err != nil {
@ -649,55 +603,22 @@ func GetExamRecordList(c *gin.Context) {
} }
examIDStr := c.Query("exam_id") examIDStr := c.Query("exam_id")
db := database.GetDB()
// 如果指定了试卷ID db := database.GetDB()
query := db.Where("user_id = ?", userID)
// 如果指定了试卷ID,只查询该试卷的记录
if examIDStr != "" { if examIDStr != "" {
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
} }
query = query.Where("exam_id = ?", examID)
// 查询试卷并检查权限
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
} }
// 检查用户是否有权限访问该试卷
if !exam.IsAccessibleBy(userID.(int64), db) {
c.JSON(http.StatusForbidden, gin.H{"success": false, "message": "无权限访问"})
return
}
// 查询该试卷的所有考试记录(包含用户信息)
// 注意:包含 in_progress 状态的记录,以支持继续答题功能
var records []models.ExamRecord var records []models.ExamRecord
if err := db.Where("exam_id = ? AND user_id = ?", examID, userID). if err := query.Preload("Exam").
Preload("Exam").
Preload("User", func(db *gorm.DB) *gorm.DB {
return db.Select("id", "username", "nickname", "avatar")
}).
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,
})
return
}
// 没有指定试卷ID返回当前用户的所有记录
var records []models.ExamRecord
if err := db.Where("user_id = ?", userID).
Preload("Exam").
Order("created_at DESC"). Order("created_at DESC").
Find(&records).Error; err != nil { Find(&records).Error; err != nil {
log.Printf("查询考试记录失败: %v", err) log.Printf("查询考试记录失败: %v", err)
@ -735,15 +656,6 @@ func DeleteExam(c *gin.Context) {
return return
} }
// 检查是否为系统试卷
if exam.IsSystem {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "系统试卷不允许删除",
})
return
}
// 软删除 // 软删除
if err := db.Delete(&exam).Error; err != nil { if err := db.Delete(&exam).Error; err != nil {
log.Printf("删除试卷失败: %v", err) log.Printf("删除试卷失败: %v", err)
@ -954,22 +866,13 @@ func ShareExam(c *gin.Context) {
db := database.GetDB() db := database.GetDB()
// 查询原始试卷,确认用户有权限分享 // 查询原始试卷
var exam models.Exam var originalExam models.Exam
if err := db.Where("id = ? AND user_id = ?", examID, userID).First(&exam).Error; err != nil { if err := db.Where("id = ? AND user_id = ?", examID, userID).First(&originalExam).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.IsSystem {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "系统试卷不允许再次分享",
})
return
}
// 开始事务 // 开始事务
tx := db.Begin() tx := db.Begin()
defer func() { defer func() {
@ -978,19 +881,19 @@ func ShareExam(c *gin.Context) {
} }
}() }()
sharedByID := userID.(int64) sharedByID := uint(userID.(int64))
sharedCount := 0 sharedCount := 0
now := time.Now()
// 为每个用户创建分享记录 // 为每个用户创建分享副本
for _, targetUserID := range req.UserIDs { for _, targetUserID := range req.UserIDs {
// 检查是否已分享 // 检查是否已分享给该用户
var existingShare models.ExamShare var existingExam models.Exam
err := tx.Where("exam_id = ? AND shared_to_id = ?", uint(examID), int64(targetUserID)). err := tx.Where("user_id = ? AND shared_by_id = ? AND question_ids = ? AND deleted_at IS NULL",
First(&existingShare).Error targetUserID, sharedByID, originalExam.QuestionIDs).First(&existingExam).Error
if err == nil { if err == nil {
continue // 已存在,跳过 // 已存在,跳过
continue
} else if err != gorm.ErrRecordNotFound { } else if err != gorm.ErrRecordNotFound {
tx.Rollback() tx.Rollback()
log.Printf("检查分享记录失败: %v", err) log.Printf("检查分享记录失败: %v", err)
@ -998,20 +901,26 @@ func ShareExam(c *gin.Context) {
return return
} }
// 创建分享记录 // 创建试卷副本
share := models.ExamShare{ sharedExam := models.Exam{
ExamID: uint(examID), UserID: targetUserID,
SharedByID: sharedByID, Title: originalExam.Title,
SharedToID: int64(targetUserID), TotalScore: originalExam.TotalScore,
SharedAt: now, Duration: originalExam.Duration,
PassScore: originalExam.PassScore,
QuestionIDs: originalExam.QuestionIDs,
Status: "active",
IsShared: true,
SharedByID: &sharedByID,
} }
if err := tx.Create(&share).Error; err != nil { if err := tx.Create(&sharedExam).Error; err != nil {
tx.Rollback() tx.Rollback()
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
} }
sharedCount++ sharedCount++
} }
@ -1030,84 +939,3 @@ func ShareExam(c *gin.Context) {
}, },
}) })
} }
// GetDailyExamRanking 获取每日一练排行榜
func GetDailyExamRanking(c *gin.Context) {
_, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"})
return
}
db := database.GetDB()
// 获取今天的每日一练试卷
today := time.Now()
title := fmt.Sprintf("%d年%02d月%02d日的每日一练",
today.Year(), today.Month(), today.Day())
var exam models.Exam
err := db.Where("is_system = ? AND title = ?", true, title).First(&exam).Error
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"message": "今日每日一练尚未生成",
})
return
}
// 查询该试卷的成绩排行榜(取最高分)
type RankingItem struct {
UserID uint `json:"user_id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
Score float64 `json:"score"`
TimeSpent int `json:"time_spent"` // 答题用时
Rank int `json:"rank"`
}
var rankings []RankingItem
// SQL查询每个用户的最高分
err = db.Table("exam_records").
Select(`
exam_records.user_id,
users.username,
users.nickname,
users.avatar,
MAX(exam_records.score) as score,
MIN(exam_records.time_spent) as time_spent
`).
Joins("JOIN users ON users.id = exam_records.user_id").
Where("exam_records.exam_id = ? AND exam_records.status = ?", exam.ID, "graded").
Group("exam_records.user_id, users.username, users.nickname, users.avatar").
Order("score DESC, time_spent ASC").
Limit(50). // 显示前50名
Find(&rankings).Error
if err != nil {
log.Printf("查询排行榜失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "查询排行榜失败",
})
return
}
// 添加排名
for i := range rankings {
rankings[i].Rank = i + 1
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"exam_id": exam.ID,
"exam_title": exam.Title,
"rankings": rankings,
"total": len(rankings),
},
})
}

View File

@ -20,38 +20,11 @@ type Exam struct {
PassScore int `gorm:"not null;default:60" json:"pass_score"` // 及格分数 PassScore int `gorm:"not null;default:60" json:"pass_score"` // 及格分数
QuestionIDs datatypes.JSON `gorm:"type:json" json:"question_ids"` // 题目ID列表 (JSON数组) QuestionIDs datatypes.JSON `gorm:"type:json" json:"question_ids"` // 题目ID列表 (JSON数组)
Status string `gorm:"type:varchar(20);not null;default:'active'" json:"status"` // 状态: active, archived Status string `gorm:"type:varchar(20);not null;default:'active'" json:"status"` // 状态: active, archived
IsSystem bool `gorm:"default:false;index" json:"is_system"` // 是否为系统试卷 IsShared bool `gorm:"default:false" json:"is_shared"` // 是否为分享的试卷
SharedByID *uint `gorm:"index" json:"shared_by_id,omitempty"` // 分享人ID (如果是分享的试卷)
// 关联关系 // 关联
Shares []ExamShare `gorm:"foreignKey:ExamID" json:"-"` // 该试卷的分享记录(作为被分享试卷) SharedBy *User `gorm:"foreignKey:SharedByID" json:"shared_by,omitempty"` // 分享人信息
SharedToMe []ExamShare `gorm:"foreignKey:SharedToID" json:"-"` // 分享给我的记录(作为接收者)
}
// IsAccessibleBy 检查用户是否有权限访问试卷
func (e *Exam) IsAccessibleBy(userID int64, db *gorm.DB) bool {
// 用户是试卷创建者
if int64(e.UserID) == userID {
return true
}
// 检查是否被分享给该用户
var count int64
db.Model(&ExamShare{}).Where("exam_id = ? AND shared_to_id = ?", e.ID, userID).Count(&count)
return count > 0
}
// GetAccessibleExams 获取用户可访问的所有试卷(拥有的+被分享的)
func GetAccessibleExams(userID int64, db *gorm.DB) ([]Exam, error) {
var exams []Exam
// 子查询被分享的试卷ID
subQuery := db.Model(&ExamShare{}).Select("exam_id").Where("shared_to_id = ?", userID)
// 查询:用户拥有的 OR 被分享的试卷
err := db.Where("user_id = ? OR id IN (?)", uint(userID), subQuery).
Order("created_at DESC").
Find(&exams).Error
return exams, err
} }
// ExamRecord 考试记录 // ExamRecord 考试记录

View File

@ -1,30 +0,0 @@
package models
import (
"time"
"gorm.io/gorm"
)
// ExamShare 试卷分享关联表
type ExamShare 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;uniqueIndex:uk_exam_shared_to" json:"exam_id"`
SharedByID int64 `gorm:"not null;index" json:"shared_by_id"`
SharedToID int64 `gorm:"not null;uniqueIndex:uk_exam_shared_to" json:"shared_to_id"`
SharedAt time.Time `gorm:"not null;default:CURRENT_TIMESTAMP" json:"shared_at"`
// 关联关系
Exam *Exam `gorm:"foreignKey:ExamID" json:"exam,omitempty"`
SharedBy *User `gorm:"foreignKey:SharedByID;references:ID" json:"shared_by,omitempty"`
SharedTo *User `gorm:"foreignKey:SharedToID;references:ID" json:"shared_to,omitempty"`
}
// TableName 指定表名
func (ExamShare) TableName() string {
return "exam_shares"
}

View File

@ -1,201 +0,0 @@
package services
import (
"ankao/internal/database"
"ankao/internal/models"
"encoding/json"
"fmt"
"log"
"math/rand"
"time"
"gorm.io/gorm"
)
type DailyExamService struct {
db *gorm.DB
}
func NewDailyExamService() *DailyExamService {
return &DailyExamService{
db: database.GetDB(),
}
}
// GenerateDailyExam 生成每日一练试卷
func (s *DailyExamService) GenerateDailyExam() error {
// 1. 获取分布式锁使用日期作为锁ID
today := time.Now().Format("20060102")
lockID := hashString(today) // 使用日期哈希作为锁ID
var locked bool
if err := s.db.Raw("SELECT pg_try_advisory_lock(?)", lockID).Scan(&locked).Error; err != nil {
return fmt.Errorf("获取锁失败: %w", err)
}
if !locked {
log.Println("其他实例正在生成每日一练,跳过")
return nil
}
defer s.db.Exec("SELECT pg_advisory_unlock(?)", lockID)
// 2. 检查今天是否已生成
todayStart := time.Now().Truncate(24 * time.Hour)
todayEnd := todayStart.Add(24 * time.Hour)
var count int64
s.db.Model(&models.Exam{}).
Where("is_system = ? AND created_at >= ? AND created_at < ?",
true, todayStart, todayEnd).
Count(&count)
if count > 0 {
log.Println("今日每日一练已生成,跳过")
return nil
}
// 3. 生成试卷标题
now := time.Now()
title := fmt.Sprintf("%d年%02d月%02d日的每日一练",
now.Year(), now.Month(), now.Day())
// 4. 随机选择题目(使用与创建试卷相同的逻辑)
questionIDs, totalScore, err := s.selectQuestions()
if err != nil {
return fmt.Errorf("选择题目失败: %w", err)
}
questionIDsJSON, _ := json.Marshal(questionIDs)
// 5. 创建试卷(使用第一个用户作为创建者,但标记为系统试卷)
// 获取第一个用户ID
var firstUser models.User
if err := s.db.Order("id ASC").First(&firstUser).Error; err != nil {
return fmt.Errorf("查询用户失败: %w", err)
}
exam := models.Exam{
UserID: uint(firstUser.ID), // 使用第一个用户作为创建者
Title: title,
TotalScore: int(totalScore),
Duration: 60,
PassScore: 80,
QuestionIDs: questionIDsJSON,
Status: "active",
IsSystem: true, // 标记为系统试卷
}
if err := s.db.Create(&exam).Error; err != nil {
return fmt.Errorf("创建试卷失败: %w", err)
}
log.Printf("成功创建每日一练试卷: ID=%d, Title=%s", exam.ID, exam.Title)
// 6. 分享给所有用户
if err := s.shareToAllUsers(exam.ID, uint(firstUser.ID)); err != nil {
log.Printf("分享试卷失败: %v", err)
// 不返回错误,因为试卷已创建成功
}
return nil
}
// selectQuestions 选择题目(复用现有逻辑)
func (s *DailyExamService) selectQuestions() ([]int64, float64, error) {
questionTypes := []struct {
Type string
Count int
Score float64
}{
{Type: "fill-in-blank", Count: 20, Score: 2.0}, // 40分
{Type: "true-false", Count: 10, Score: 1.0}, // 10分
{Type: "multiple-choice", Count: 10, Score: 1.0}, // 10分
{Type: "multiple-selection", Count: 10, Score: 2.0}, // 20分
{Type: "short-answer", Count: 1, Score: 10.0}, // 10分
{Type: "ordinary-essay", Count: 1, Score: 10.0}, // 10分普通涉密人员论述题
{Type: "management-essay", Count: 1, Score: 10.0}, // 10分保密管理人员论述题
}
var allQuestionIDs []int64
var totalScore float64
for _, qt := range questionTypes {
var questions []models.PracticeQuestion
if err := s.db.Where("type = ?", qt.Type).Find(&questions).Error; err != nil {
return nil, 0, err
}
// 检查题目数量是否足够
if len(questions) < qt.Count {
return nil, 0, fmt.Errorf("题型 %s 题目数量不足,需要 %d 道,实际 %d 道",
qt.Type, qt.Count, len(questions))
}
// 随机抽取 (Fisher-Yates 洗牌算法)
rand.Seed(time.Now().UnixNano())
for i := len(questions) - 1; i > 0; i-- {
j := rand.Intn(i + 1)
questions[i], questions[j] = questions[j], questions[i]
}
selectedQuestions := questions[:qt.Count]
for _, q := range selectedQuestions {
allQuestionIDs = append(allQuestionIDs, q.ID)
}
totalScore += float64(qt.Count) * qt.Score
}
// 随机打乱题目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]
}
return allQuestionIDs, totalScore, nil
}
// shareToAllUsers 分享给所有用户
func (s *DailyExamService) shareToAllUsers(examID uint, sharedByID uint) error {
// 查询所有用户(排除创建者)
var users []models.User
if err := s.db.Where("id != ?", sharedByID).Find(&users).Error; err != nil {
return err
}
// 批量创建分享记录
now := time.Now()
shares := make([]models.ExamShare, 0, len(users))
for _, user := range users {
shares = append(shares, models.ExamShare{
ExamID: examID,
SharedByID: int64(sharedByID),
SharedToID: int64(user.ID),
SharedAt: now,
})
}
if len(shares) > 0 {
// 批量插入
if err := s.db.Create(&shares).Error; err != nil {
return err
}
log.Printf("成功分享给 %d 个用户", len(shares))
}
return nil
}
// hashString 计算字符串哈希值用于生成锁ID
func hashString(s string) int64 {
var hash int64
for _, c := range s {
hash = hash*31 + int64(c)
}
// 确保返回正数
if hash < 0 {
hash = -hash
}
return hash
}

55
main.go
View File

@ -4,13 +4,11 @@ import (
"ankao/internal/database" "ankao/internal/database"
"ankao/internal/handlers" "ankao/internal/handlers"
"ankao/internal/middleware" "ankao/internal/middleware"
"ankao/internal/services"
"log" "log"
"net/http" "net/http"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/robfig/cron/v3"
) )
func main() { func main() {
@ -83,7 +81,6 @@ func main() {
auth.GET("/exam-records/:record_id/answers", handlers.GetExamUserAnswers) // 获取用户答案 auth.GET("/exam-records/:record_id/answers", handlers.GetExamUserAnswers) // 获取用户答案
auth.GET("/users/shareable", handlers.GetShareableUsers) // 获取可分享的用户列表 auth.GET("/users/shareable", handlers.GetShareableUsers) // 获取可分享的用户列表
auth.POST("/exams/:id/share", handlers.ShareExam) // 分享试卷 auth.POST("/exams/:id/share", handlers.ShareExam) // 分享试卷
auth.GET("/daily-exam/ranking", handlers.GetDailyExamRanking) // 获取每日一练排行榜
} }
// 题库管理API需要管理员权限 // 题库管理API需要管理员权限
@ -117,12 +114,6 @@ func main() {
MaxHeaderBytes: 1 << 20, // 最大请求头1MB MaxHeaderBytes: 1 << 20, // 最大请求头1MB
} }
// 启动定时任务
startCronJobs()
// 应用启动时检测并生成今日每日一练
go checkAndGenerateDailyExam()
log.Printf("服务器启动在端口 %s超时配置读/写 5分钟", port) log.Printf("服务器启动在端口 %s超时配置读/写 5分钟", port)
// 启动服务器 // 启动服务器
@ -130,49 +121,3 @@ func main() {
panic("服务器启动失败: " + err.Error()) panic("服务器启动失败: " + err.Error())
} }
} }
// startCronJobs 启动定时任务
func startCronJobs() {
// 创建定时任务调度器(使用中国时区 UTC+8
c := cron.New(cron.WithLocation(time.FixedZone("CST", 8*3600)))
// 每天凌晨1点执行
_, err := c.AddFunc("0 1 * * *", func() {
log.Println("开始生成每日一练...")
service := services.NewDailyExamService()
if err := service.GenerateDailyExam(); err != nil {
log.Printf("生成每日一练失败: %v", err)
} else {
log.Println("每日一练生成成功")
}
})
if err != nil {
log.Printf("添加定时任务失败: %v", err)
return
}
// 启动调度器
c.Start()
log.Println("定时任务已启动每天凌晨1点生成每日一练")
// 可选:应用启动时立即生成一次(用于测试)
// go func() {
// log.Println("应用启动,立即生成一次每日一练...")
// service := services.NewDailyExamService()
// if err := service.GenerateDailyExam(); err != nil {
// log.Printf("生成每日一练失败: %v", err)
// }
// }()
}
// checkAndGenerateDailyExam 检测并生成今日每日一练
func checkAndGenerateDailyExam() {
log.Println("检测今日每日一练是否已生成...")
service := services.NewDailyExamService()
if err := service.GenerateDailyExam(); err != nil {
log.Printf("生成每日一练失败: %v", err)
} else {
log.Println("每日一练检测完成")
}
}

View File

@ -94,24 +94,3 @@ export const getExam = (examId: number, showAnswer?: boolean) => {
export const submitExam = (examId: number, data: SubmitExamRequest) => { export const submitExam = (examId: number, data: SubmitExamRequest) => {
return request.post<ApiResponse<SubmitExamResponse>>(`/exam/${examId}/submit`, data) return request.post<ApiResponse<SubmitExamResponse>>(`/exam/${examId}/submit`, data)
} }
// 获取每日一练排行榜
export const getDailyExamRanking = () => {
return request.get<{
success: boolean
data: {
exam_id: number
exam_title: string
rankings: Array<{
user_id: number
username: string
nickname: string
avatar: string
score: number
time_spent: number
rank: number
}>
total: number
}
}>('/daily-exam/ranking')
}

View File

@ -136,7 +136,7 @@
// 卡片内容样式 // 卡片内容样式
.cardContent { .cardContent {
padding: 8px; padding: 16px;
} }
.examInfo { .examInfo {
@ -208,12 +208,6 @@
width: 100%; width: 100%;
justify-content: center; // 内容居中 justify-content: center; // 内容居中
// 覆盖 antd Tag 的默认图标间距
:global(.anticon) + span,
span + :global(.anticon) {
margin-inline-start: 0 !important;
}
.anticon { .anticon {
font-size: 14px; font-size: 14px;
color: #64748b; color: #64748b;
@ -236,12 +230,7 @@
line-height: 1.4; line-height: 1.4;
width: 100%; width: 100%;
justify-content: center; // 内容居中 justify-content: center; // 内容居中
width: 100%;
// 覆盖 antd Tag 的默认图标间距
:global(.anticon) + span,
span + :global(.anticon) {
margin-inline-start: 0 !important;
}
.anticon { .anticon {
font-size: 14px; font-size: 14px;
@ -364,6 +353,7 @@
} }
.cardContent { .cardContent {
margin-top: 12px;
.infoRow { .infoRow {
display: flex; display: flex;
@ -398,7 +388,6 @@
// 旧版兼容样式 - divider已合并不再重复定义 // 旧版兼容样式 - divider已合并不再重复定义
// 响应式适配 // 响应式适配
// 移动端1列
@media (max-width: 768px) { @media (max-width: 768px) {
.container { .container {
padding: 16px; padding: 16px;
@ -406,7 +395,7 @@
} }
.examGrid { .examGrid {
grid-template-columns: 1fr; // 移动端显示1列 grid-template-columns: 1fr;
gap: 16px; gap: 16px;
} }
@ -483,7 +472,7 @@
.examStats { .examStats {
padding: 12px 0; padding: 12px 0;
gap: 6px; gap: 10px;
.statItem { .statItem {
.valueTag { .valueTag {
@ -582,14 +571,6 @@
} }
} }
// 平板端2列
@media (min-width: 769px) and (max-width: 1024px) {
.examGrid {
grid-template-columns: repeat(2, 1fr); // 平板显示2列
gap: 12px;
}
}
@media (max-width: 480px) { @media (max-width: 480px) {
.container { .container {
padding: 12px; padding: 12px;

View File

@ -30,9 +30,7 @@ import {
PrinterOutlined, PrinterOutlined,
ArrowLeftOutlined, ArrowLeftOutlined,
ShareAltOutlined, ShareAltOutlined,
UserOutlined, UserOutlined
TeamOutlined,
CrownOutlined
} from '@ant-design/icons' } from '@ant-design/icons'
import * as examApi from '../api/exam' import * as examApi from '../api/exam'
import styles from './ExamManagement.module.less' import styles from './ExamManagement.module.less'
@ -48,10 +46,8 @@ interface ExamListItem {
best_score: number best_score: number
has_in_progress_exam: boolean has_in_progress_exam: boolean
in_progress_record_id?: number in_progress_record_id?: number
participant_count: number // 共享试卷的参与人数
created_at: string created_at: string
is_shared?: boolean is_shared?: boolean
is_system?: boolean // 是否为系统试卷
shared_by?: { shared_by?: {
id: number id: number
username: string username: string
@ -301,19 +297,10 @@ const ExamManagement: React.FC = () => {
<div className={styles.coverInfo}> <div className={styles.coverInfo}>
<h3 <h3
className={styles.examTitle} className={styles.examTitle}
style={{ marginBottom: (exam.is_system || (exam.is_shared && exam.shared_by)) ? '12px' : '0' }} style={{ marginBottom: exam.is_shared && exam.shared_by ? '12px' : '0' }}
> >
{exam.title} {exam.title}
</h3> </h3>
{exam.is_system && (
<Tag
icon={<CrownOutlined />}
color="orange"
className={styles.shareTag}
>
</Tag>
)}
{exam.is_shared && exam.shared_by && ( {exam.is_shared && exam.shared_by && (
<Tag <Tag
icon={<ShareAltOutlined />} icon={<ShareAltOutlined />}
@ -335,8 +322,6 @@ const ExamManagement: React.FC = () => {
> >
{exam.has_in_progress_exam ? '继续' : '考试'} {exam.has_in_progress_exam ? '继续' : '考试'}
</Button>, </Button>,
// 只有非系统且自己创建的试卷才能分享
!exam.is_system && !exam.is_shared && (
<Button <Button
type="text" type="text"
icon={<ShareAltOutlined />} icon={<ShareAltOutlined />}
@ -344,8 +329,7 @@ const ExamManagement: React.FC = () => {
className={styles.actionButton} className={styles.actionButton}
> >
</Button> </Button>,
),
<Button <Button
type="text" type="text"
icon={<HistoryOutlined />} icon={<HistoryOutlined />}
@ -370,8 +354,6 @@ const ExamManagement: React.FC = () => {
> >
</Button>, </Button>,
// 只有非系统且自己创建的试卷才能删除
!exam.is_system && !exam.is_shared && (
<Button <Button
type="text" type="text"
danger danger
@ -381,10 +363,25 @@ const ExamManagement: React.FC = () => {
> >
</Button> </Button>
) ]}
].filter(Boolean)}
> >
<div className={styles.cardContent}> <div className={styles.cardContent}>
<div className={styles.examInfo}>
<div className={styles.infoItem}>
<ClockCircleOutlined className={styles.infoIcon} />
<span className={styles.infoText}>{exam.duration} </span>
</div>
<div className={styles.infoItem}>
<CheckCircleOutlined className={styles.infoIcon} />
<span className={styles.infoText}> {exam.pass_score} </span>
</div>
<div className={styles.infoItem}>
<FileTextOutlined className={styles.infoIcon} />
<span className={styles.infoText}>{exam.question_count || 0} </span>
</div>
</div>
<Divider className={styles.divider} />
<div className={styles.examStats}> <div className={styles.examStats}>
<div className={styles.statItem}> <div className={styles.statItem}>
@ -399,14 +396,6 @@ const ExamManagement: React.FC = () => {
<span> {exam.attempt_count}</span> <span> {exam.attempt_count}</span>
</Tag> </Tag>
</div> </div>
{exam.participant_count > 0 && (
<div className={styles.statItem}>
<Tag className={styles.valueTag}>
<TeamOutlined />
<span>{exam.participant_count} </span>
</Tag>
</div>
)}
{exam.has_in_progress_exam && ( {exam.has_in_progress_exam && (
<div className={styles.statItem}> <div className={styles.statItem}>
<Tag color="processing" className={styles.progressTag}> <Tag color="processing" className={styles.progressTag}>
@ -444,14 +433,6 @@ const ExamManagement: React.FC = () => {
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
size="small" size="small"
> >
{record.user && (
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', gap: 8 }}>
<Avatar src={record.user.avatar} icon={<UserOutlined />} />
<span style={{ fontWeight: 'bold' }}>
{record.user.nickname || record.user.username}
</span>
</div>
)}
<Descriptions column={1} size="small"> <Descriptions column={1} size="small">
<Descriptions.Item label="状态"> <Descriptions.Item label="状态">
{record.status === 'in_progress' && <Tag color="processing"></Tag>} {record.status === 'in_progress' && <Tag color="processing"></Tag>}
@ -466,14 +447,8 @@ const ExamManagement: React.FC = () => {
{record.status === 'in_progress' ? ( {record.status === 'in_progress' ? (
<span>-</span> <span>-</span>
) : ( ) : (
<span style={{ <span style={{ fontSize: 18, fontWeight: 'bold', color: record.is_passed ? '#52c41a' : '#ff4d4f' }}>
fontSize: 18, {record.score} / {record.total_score}
fontWeight: 'bold',
color: record.is_passed ? '#52c41a' : '#ff4d4f',
lineHeight: 1,
verticalAlign: 'middle'
}}>
{record.score}
</span> </span>
)} )}
</Descriptions.Item> </Descriptions.Item>
@ -511,6 +486,18 @@ const ExamManagement: React.FC = () => {
</Button> </Button>
)} )}
{record.status === 'graded' && (
<Button
size="small"
icon={<FileTextOutlined />}
onClick={() => {
setRecordsDrawerVisible(false)
navigate(`/exam/result/${record.id}`)
}}
>
</Button>
)}
</Space> </Space>
</Card> </Card>
)} )}

View File

@ -205,7 +205,7 @@
display: none !important; display: none !important;
} }
// A4纸张设置 // A4纸张设置 - 增加边距以确保圆角不被遮挡
@page { @page {
size: A4; size: A4;
margin: 1cm; margin: 1cm;
@ -225,9 +225,6 @@
.paperHeader { .paperHeader {
margin-bottom: 6px; margin-bottom: 6px;
padding-bottom: 4px; padding-bottom: 4px;
// 防止试卷标题和考试说明分页
page-break-after: avoid;
page-break-inside: avoid;
.paperTitle { .paperTitle {
font-size: 16pt !important; font-size: 16pt !important;
@ -259,9 +256,6 @@
overflow: visible; overflow: visible;
// 添加一些内边距确保圆角有空间显示 // 添加一些内边距确保圆角有空间显示
padding: 2px; padding: 2px;
// 防止考试说明和填空题分页
page-break-after: avoid;
page-break-inside: avoid;
:global(.ant-card-body) { :global(.ant-card-body) {
padding: 6px; padding: 6px;
@ -286,18 +280,10 @@
.questionGroup { .questionGroup {
margin-bottom: 8px; margin-bottom: 8px;
// 防止题型组内部分页
page-break-inside: avoid;
// 尽量让下一个题型紧接着显示
page-break-after: avoid;
.groupHeader { .groupHeader {
display: flex;
align-items: baseline;
margin-bottom: 4px; margin-bottom: 4px;
padding-bottom: 2px; padding-bottom: 2px;
// 确保题型标题和第一道题在同一页
page-break-after: avoid;
.groupTitle { .groupTitle {
font-size: 12pt; font-size: 12pt;
@ -305,15 +291,12 @@
.groupScore { .groupScore {
font-size: 10pt; font-size: 10pt;
margin-left: 8px;
} }
} }
} }
.questionItem { .questionItem {
margin-bottom: 6px; margin-bottom: 6px;
// 防止题目内部分页,保持题目完整性
page-break-inside: avoid;
.questionContent { .questionContent {
margin-bottom: 3px; margin-bottom: 3px;

View File

@ -42,12 +42,12 @@ const TYPE_NAME: Record<string, string> = {
// 题型分值映射 // 题型分值映射
const TYPE_SCORE: Record<string, number> = { const TYPE_SCORE: Record<string, number> = {
'fill-in-blank': 2.0, 'fill-in-blank': 2.0,
'true-false': 1.0, 'true-false': 2.0,
'multiple-choice': 1.0, 'multiple-choice': 1.0,
'multiple-selection': 2.0, 'multiple-selection': 2.5,
'short-answer': 0, // 不计分 'short-answer': 0, // 不计分
'ordinary-essay': 10.0, 'ordinary-essay': 4.5,
'management-essay': 10.0, 'management-essay': 4.5,
} }
const ExamPrint: React.FC = () => { const ExamPrint: React.FC = () => {
@ -352,14 +352,14 @@ const ExamPrint: React.FC = () => {
return ( return (
<div key={type} className={styles.questionGroup}> <div key={type} className={styles.questionGroup}>
<div className={styles.groupHeader}> <div className={styles.groupHeader}>
<span className={styles.groupTitle}> <Text className={styles.groupTitle}>
{TYPE_NAME[type]} {TYPE_NAME[type]}
</Text>
{TYPE_SCORE[type] > 0 && ( {TYPE_SCORE[type] > 0 && (
<span className={styles.groupScore} style={{ marginLeft: '8px' }}> <Text type="secondary" className={styles.groupScore}>
{questions.length}{TYPE_SCORE[type]}{totalScore} {questions.length}{TYPE_SCORE[type]}{totalScore}
</span> </Text>
)} )}
</span>
</div> </div>
<div className={styles.questionsList}> <div className={styles.questionsList}>
{questions.map((question, index) => { {questions.map((question, index) => {
@ -448,8 +448,8 @@ const ExamPrint: React.FC = () => {
<Title level={4}></Title> <Title level={4}></Title>
<ul> <ul>
<li>10060</li> <li>10060</li>
<li></li> <li>8</li>
<li>21</li> <li>921</li>
</ul> </ul>
</Card> </Card>
)} )}
@ -464,12 +464,12 @@ const ExamPrint: React.FC = () => {
{essayQuestions.length > 0 && ( {essayQuestions.length > 0 && (
<div className={styles.questionGroup}> <div className={styles.questionGroup}>
<div className={styles.groupHeader}> <div className={styles.groupHeader}>
<span className={styles.groupTitle}> <Text className={styles.groupTitle}>
{TYPE_NAME['ordinary-essay']} {TYPE_NAME['ordinary-essay']}
<span className={styles.groupScore} style={{ marginLeft: '8px' }}> </Text>
2110 <Text type="secondary" className={styles.groupScore}>
</span> 219
</span> </Text>
</div> </div>
<div className={styles.questionsList}> <div className={styles.questionsList}>
{essayQuestions.map((question, index) => renderEssay(question, index))} {essayQuestions.map((question, index) => renderEssay(question, index))}

View File

@ -348,16 +348,6 @@ const ExamResultNew: React.FC = () => {
return ( return (
<div className={styles.container}> <div className={styles.container}>
{/* 顶部返回按钮 */}
<div style={{ marginBottom: 16 }}>
<Button
icon={<LeftOutlined />}
onClick={() => navigate(-1)}
>
</Button>
</div>
{/* 成绩展示 */} {/* 成绩展示 */}
<Result <Result
status={isPassed ? "success" : "warning"} status={isPassed ? "success" : "warning"}
@ -369,29 +359,6 @@ const ExamResultNew: React.FC = () => {
</Text> </Text>
</Space> </Space>
} }
extra={
!isPassed && record.exam?.id ? (
<Button
type="primary"
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}`);
} else {
message.error(res.message || "开始考试失败");
}
} catch (error) {
message.error("开始考试失败");
}
}}
>
</Button>
) : undefined
}
/> />
{/* 成绩统计 */} {/* 成绩统计 */}
@ -581,6 +548,39 @@ const ExamResultNew: React.FC = () => {
</div> </div>
))} ))}
</Card> </Card>
{/* 操作按钮 */}
<Card className={styles.actionsCard}>
<Space size="large">
<Button
size="large"
icon={<LeftOutlined />}
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> </div>
); );
}; };

View File

@ -24,7 +24,6 @@ import {
CrownOutlined, CrownOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import * as questionApi from '../api/question' import * as questionApi from '../api/question'
import * as examApi from '../api/exam'
import { fetchWithAuth } from '../utils/request' import { fetchWithAuth } from '../utils/request'
import type { Statistics } from '../types/question' import type { Statistics } from '../types/question'
import styles from './Home.module.less' import styles from './Home.module.less'
@ -108,25 +107,8 @@ const Home: React.FC = () => {
const [dailyRanking, setDailyRanking] = useState<questionApi.UserStats[]>([]) const [dailyRanking, setDailyRanking] = useState<questionApi.UserStats[]>([])
const [totalRanking, setTotalRanking] = useState<questionApi.UserStats[]>([]) const [totalRanking, setTotalRanking] = useState<questionApi.UserStats[]>([])
const [rankingLoading, setRankingLoading] = useState(false) const [rankingLoading, setRankingLoading] = useState(false)
const [rankingType, setRankingType] = useState<'daily-exam' | 'daily' | 'total'>('daily-exam') // 排行榜类型:每日一练、每日或总榜 const [rankingType, setRankingType] = useState<'daily' | 'total'>('daily') // 排行榜类型:每日或总榜
const [sliderPosition, setSliderPosition] = useState<'left' | 'center' | 'right'>('left') // 滑块位置 const [sliderPosition, setSliderPosition] = useState<'left' | 'right'>('left') // 滑块位置
// 每日一练排行榜状态
const [dailyExamRanking, setDailyExamRanking] = useState<{
exam_id?: number
exam_title?: string
rankings: Array<{
user_id: number
username: string
nickname: string
avatar: string
score: number
time_spent: number
rank: number
}>
total: number
}>({ rankings: [], total: 0 })
const [dailyExamLoading, setDailyExamLoading] = useState(false)
// 答题设置状态 // 答题设置状态
const [autoNext, setAutoNext] = useState(() => { const [autoNext, setAutoNext] = useState(() => {
@ -144,12 +126,6 @@ const Home: React.FC = () => {
return saved !== null ? saved === 'true' : false return saved !== null ? saved === 'true' : false
}) })
// 公告显示状态
const [showAnnouncement, setShowAnnouncement] = useState(() => {
const dismissed = localStorage.getItem('announcementDismissed')
return dismissed !== 'true'
})
// 加载统计数据 // 加载统计数据
const loadStatistics = async () => { const loadStatistics = async () => {
try { try {
@ -194,9 +170,7 @@ const Home: React.FC = () => {
// 加载当前选中的排行榜数据 // 加载当前选中的排行榜数据
const loadCurrentRanking = async () => { const loadCurrentRanking = async () => {
if (rankingType === 'daily-exam') { if (rankingType === 'daily') {
await loadDailyExamRanking()
} else if (rankingType === 'daily') {
await loadDailyRanking() await loadDailyRanking()
} else { } else {
await loadTotalRanking() await loadTotalRanking()
@ -204,32 +178,9 @@ const Home: React.FC = () => {
} }
// 切换排行榜类型 // 切换排行榜类型
const switchRankingType = (type: 'daily-exam' | 'daily' | 'total') => { const switchRankingType = (type: 'daily' | 'total') => {
setRankingType(type) setRankingType(type)
if (type === 'daily-exam') { setSliderPosition(type === 'daily' ? 'left' : 'right')
setSliderPosition('left')
} else if (type === 'daily') {
setSliderPosition('center')
} else {
setSliderPosition('right')
}
}
// 加载每日一练排行榜
const loadDailyExamRanking = async () => {
setDailyExamLoading(true)
try {
const res = await examApi.getDailyExamRanking()
if (res.success && res.data) {
setDailyExamRanking(res.data)
}
} catch (error) {
console.error('加载每日一练排行榜失败:', error)
// 如果失败,设置为空数据(可能是今日尚未生成)
setDailyExamRanking({ rankings: [], total: 0 })
} finally {
setDailyExamLoading(false)
}
} }
// 加载用户信息 // 加载用户信息
@ -410,12 +361,6 @@ const Home: React.FC = () => {
} }
} }
// 关闭公告
const handleCloseAnnouncement = () => {
setShowAnnouncement(false)
localStorage.setItem('announcementDismissed', 'true')
}
// 获取用户类型显示文本 // 获取用户类型显示文本
const getUserTypeText = (type?: string) => { const getUserTypeText = (type?: string) => {
if (!type) return '未设置' if (!type) return '未设置'
@ -572,28 +517,6 @@ const Home: React.FC = () => {
)} )}
</div> </div>
{/* 更新公告 */}
{showAnnouncement && (
<Alert
message="系统更新公告"
description={
<div>
<p style={{ marginBottom: 8 }}></p>
<ul style={{ marginBottom: 0, paddingLeft: 20 }}>
<li> 线</li>
<li>🏆 </li>
<li>📝 </li>
</ul>
</div>
}
type="info"
showIcon
closable
onClose={handleCloseAnnouncement}
style={{ marginBottom: 24 }}
/>
)}
{/* 题型选择 */} {/* 题型选择 */}
<div className={styles.typeSection}> <div className={styles.typeSection}>
<Title level={4} className={styles.sectionTitle}> <Title level={4} className={styles.sectionTitle}>
@ -638,67 +561,6 @@ const Home: React.FC = () => {
<RocketOutlined /> <RocketOutlined />
</Title> </Title>
<Row gutter={[12, 12]}> <Row gutter={[12, 12]}>
{/* 每日一练快捷入口 */}
<Col xs={24} sm={24} md={12} lg={8}>
<Card
hoverable
className={styles.quickCard}
onClick={async () => {
// 检查今日每日一练是否存在
if (dailyExamRanking.exam_id) {
try {
// 先查询该试卷是否有未完成的考试记录
const recordListRes = await examApi.getExamRecordList(dailyExamRanking.exam_id)
if (recordListRes.success && recordListRes.data) {
// 查找状态为 in_progress 的记录
const inProgressRecord = recordListRes.data.find(
record => record.status === 'in_progress'
)
if (inProgressRecord) {
// 如果有未完成的记录,继续之前的答题
navigate(`/exam/${dailyExamRanking.exam_id}/taking/${inProgressRecord.id}`)
return
}
}
// 如果没有未完成的记录调用开始考试API创建新记录
const res = await examApi.startExam(dailyExamRanking.exam_id)
if (res.success && res.data) {
// 跳转到考试答题页面(注意路由是 /taking 不是 /take
navigate(`/exam/${dailyExamRanking.exam_id}/taking/${res.data.record_id}`)
} else {
message.error(res.message || '开始考试失败')
}
} catch (error) {
console.error('开始每日一练失败:', error)
message.error('开始考试失败,请稍后再试')
}
} else {
message.warning('今日每日一练尚未生成,请稍后再试')
}
}}
>
<Space align="center" size="middle" style={{ width: '100%' }}>
<div
className={styles.quickIconWrapper}
style={{
background: 'linear-gradient(135deg, #fff7e6 0%, #ffe7ba 100%)',
borderColor: '#ffd591'
}}
>
<CrownOutlined className={styles.quickIcon} style={{ color: '#fa8c16' }} />
</div>
<div style={{ flex: 1 }}>
<Title level={5} style={{ margin: 0 }}></Title>
<Paragraph type="secondary" style={{ margin: 0, fontSize: '13px' }}>
{dailyExamRanking.exam_title ? dailyExamRanking.exam_title : '今日练习,冲刺高分'}
</Paragraph>
</div>
</Space>
</Card>
</Col>
<Col xs={24} sm={24} md={12} lg={8}> <Col xs={24} sm={24} md={12} lg={8}>
<Card <Card
hoverable hoverable
@ -832,12 +694,6 @@ const Home: React.FC = () => {
<TrophyOutlined /> <TrophyOutlined />
</Title> </Title>
<div className={styles.rankingSwitch}> <div className={styles.rankingSwitch}>
<div
className={`${styles.rankingSwitchButton} ${rankingType === 'daily-exam' ? styles.active : ''}`}
onClick={() => switchRankingType('daily-exam')}
>
</div>
<div <div
className={`${styles.rankingSwitchButton} ${rankingType === 'daily' ? styles.active : ''}`} className={`${styles.rankingSwitchButton} ${rankingType === 'daily' ? styles.active : ''}`}
onClick={() => switchRankingType('daily')} onClick={() => switchRankingType('daily')}
@ -853,87 +709,13 @@ const Home: React.FC = () => {
<div <div
className={styles.rankingSwitchSlider} className={styles.rankingSwitchSlider}
style={{ style={{
width: 'calc(33.33% - 4px)', width: 'calc(50% - 4px)',
left: sliderPosition === 'left' ? '4px' : sliderPosition === 'center' ? 'calc(33.33% + 0px)' : 'calc(66.66% - 4px)', left: sliderPosition === 'left' ? '4px' : 'calc(50% + 0px)',
}} }}
/> />
</div> </div>
{(rankingLoading || dailyExamLoading) ? ( {rankingLoading ? (
<Card className={styles.rankingCard} loading={true} /> <Card className={styles.rankingCard} loading={true} />
) : rankingType === 'daily-exam' ? (
dailyExamRanking.rankings.length === 0 ? (
<Card className={styles.rankingCard}>
<div style={{ textAlign: 'center', padding: '40px 20px', color: '#8c8c8c' }}>
<CrownOutlined style={{ fontSize: 48, marginBottom: 16, opacity: 0.3, color: '#fa8c16' }} />
<div></div>
<div style={{ fontSize: 13, marginTop: 8 }}>1</div>
</div>
</Card>
) : (
<Card className={styles.rankingCard}>
{dailyExamRanking.exam_title && (
<div style={{
padding: '12px 16px',
background: 'linear-gradient(135deg, #fff7e6 0%, #ffe7ba 100%)',
borderRadius: '8px',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<FileTextOutlined style={{ color: '#fa8c16', fontSize: 16 }} />
<Text strong style={{ color: '#fa8c16' }}>{dailyExamRanking.exam_title}</Text>
</div>
)}
<div className={styles.rankingList}>
{dailyExamRanking.rankings.map((user, index) => (
<div key={user.user_id} className={styles.rankingItem}>
<div className={styles.rankingLeft}>
{index < 3 ? (
<div className={`${styles.rankBadge} ${styles[`rank${index + 1}`]}`}>
{index === 0 && <CrownOutlined />}
{index === 1 && <CrownOutlined />}
{index === 2 && <CrownOutlined />}
</div>
) : (
<div className={styles.rankNumber}>{index + 1}</div>
)}
<Avatar
src={user.avatar || undefined}
size={40}
icon={<UserOutlined />}
className={styles.rankAvatar}
/>
<div className={styles.rankUserInfo}>
<div className={styles.rankNickname}>{user.nickname}</div>
<div className={styles.rankUsername}>@{user.username}</div>
</div>
</div>
<div className={styles.rankingRight}>
<div className={styles.rankStat}>
<div className={styles.rankStatValue} style={{
color: user.score >= 80 ? '#52c41a' : user.score >= 60 ? '#faad14' : '#ff4d4f',
fontSize: 18,
fontWeight: 'bold'
}}>
{user.score}
</div>
<div className={styles.rankStatLabel}></div>
</div>
<div className={styles.rankDivider}></div>
<div className={styles.rankStat}>
<div className={styles.rankStatValue}>
{Math.floor(user.time_spent / 60)}'
{user.time_spent % 60 < 10 ? '0' : ''}{user.time_spent % 60}"
</div>
<div className={styles.rankStatLabel}></div>
</div>
</div>
</div>
))}
</div>
</Card>
)
) : rankingType === 'daily' ? ( ) : rankingType === 'daily' ? (
dailyRanking.length === 0 ? ( dailyRanking.length === 0 ? (
<Card className={styles.rankingCard}> <Card className={styles.rankingCard}>

View File

@ -61,12 +61,6 @@ export interface ExamRecord {
status: 'in_progress' | 'submitted' | 'graded' status: 'in_progress' | 'submitted' | 'graded'
is_passed: boolean is_passed: boolean
exam?: ExamModel exam?: ExamModel
user?: { // 用户信息(共享试卷时返回)
id: number
username: string
nickname: string
avatar: string
}
created_at: string created_at: string
updated_at: string updated_at: string
} }
@ -104,14 +98,6 @@ export type ExamListResponse = Array<{
best_score: number best_score: number
has_in_progress_exam: boolean has_in_progress_exam: boolean
in_progress_record_id?: number in_progress_record_id?: number
participant_count: number // 共享试卷的参与人数
is_shared: boolean // 是否为被分享的试卷(原:是否为分享副本)
shared_by_id?: number // 分享人ID已废弃使用 shared_by
shared_by?: { // 分享人信息(原:副本创建者)
id: number
username: string
nickname: string
}
created_at: string created_at: string
}> }>

View File

@ -31,7 +31,7 @@ export default defineConfig({
port: 3000, port: 3000,
proxy: { proxy: {
'/api': { '/api': {
target: 'http://127.0.0.1:8080', target: 'https://ankao.yuchat.top',
changeOrigin: true, changeOrigin: true,
}, },
}, },