Compare commits
No commits in common. "f9a5e06df2c18aca0fcf76041a63f324c6dc62c2" and "4f95514af8dafe4dfa6007b3218daf8e46889fee" have entirely different histories.
f9a5e06df2
...
4f95514af8
@ -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")
|
||||
}
|
||||
@ -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
1
go.mod
@ -41,7 +41,6 @@ require (
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // 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/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
|
||||
2
go.sum
2
go.sum
@ -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/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
|
||||
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/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
|
||||
@ -38,7 +38,6 @@ func InitDB() error {
|
||||
&models.WrongQuestionHistory{}, // 错题历史表
|
||||
&models.UserAnswerRecord{}, // 用户答题记录表
|
||||
&models.Exam{}, // 考试表(试卷)
|
||||
&models.ExamShare{}, // 试卷分享关联表
|
||||
&models.ExamRecord{}, // 考试记录表
|
||||
&models.ExamUserAnswer{}, // 用户答案表
|
||||
)
|
||||
|
||||
@ -68,13 +68,13 @@ func gradeExam(recordID uint, examID uint, userID uint) {
|
||||
|
||||
// 使用固定的题型分值映射
|
||||
scoreMap := map[string]float64{
|
||||
"fill-in-blank": 2.0, // 填空题每题2分
|
||||
"true-false": 1.0, // 判断题每题1分
|
||||
"multiple-choice": 1.0, // 单选题每题1分
|
||||
"multiple-selection": 2.0, // 多选题每题2分
|
||||
"short-answer": 10.0, // 简答题10分
|
||||
"ordinary-essay": 10.0, // 论述题10分
|
||||
"management-essay": 10.0, // 论述题10分
|
||||
"fill-in-blank": 2.0,
|
||||
"true-false": 2.0,
|
||||
"multiple-choice": 1.0,
|
||||
"multiple-selection": 2.5,
|
||||
"short-answer": 8.0, // 简答题 8 分
|
||||
"ordinary-essay": 9.0, // 论述题 9 分
|
||||
"management-essay": 9.0, // 论述题 9 分
|
||||
}
|
||||
|
||||
// 评分
|
||||
|
||||
@ -46,13 +46,13 @@ func CreateExam(c *gin.Context) {
|
||||
|
||||
// 使用固定的题型配置(总分100分)
|
||||
questionTypes := []models.QuestionTypeConfig{
|
||||
{Type: "fill-in-blank", Count: 20, Score: 2.0}, // 40分
|
||||
{Type: "true-false", Count: 10, Score: 1.0}, // 10分
|
||||
{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.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分(保密管理人员论述题)
|
||||
{Type: "multiple-selection", Count: 10, Score: 2.5}, // 25分
|
||||
{Type: "short-answer", Count: 2, Score: 10.0}, // 20分
|
||||
{Type: "ordinary-essay", Count: 1, Score: 4.5}, // 4.5分(普通涉密人员论述题)
|
||||
{Type: "management-essay", Count: 1, Score: 4.5}, // 4.5分(保密管理人员论述题)
|
||||
}
|
||||
|
||||
// 按题型配置随机抽取题目
|
||||
@ -152,67 +152,44 @@ func GetExamList(c *gin.Context) {
|
||||
|
||||
db := database.GetDB()
|
||||
|
||||
// 获取用户可访问的试卷(拥有的 + 被分享的)
|
||||
exams, err := models.GetAccessibleExams(userID.(int64), db)
|
||||
if err != nil {
|
||||
// 查询用户创建的试卷(包括被分享的试卷)
|
||||
var exams []models.Exam
|
||||
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)
|
||||
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"` // 最高分(当前用户)
|
||||
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
|
||||
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))
|
||||
for _, exam := range exams {
|
||||
var questionIDs []uint
|
||||
if err := json.Unmarshal(exam.QuestionIDs, &questionIDs); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(exam.QuestionIDs, &questionIDs); err == nil {
|
||||
stats := ExamWithStats{
|
||||
Exam: exam,
|
||||
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
|
||||
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").
|
||||
@ -220,6 +197,7 @@ func GetExamList(c *gin.Context) {
|
||||
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").
|
||||
@ -228,16 +206,9 @@ func GetExamList(c *gin.Context) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
@ -508,32 +479,15 @@ func GetExamRecord(c *gin.Context) {
|
||||
|
||||
db := database.GetDB()
|
||||
|
||||
// 查询考试记录(不限制用户,因为可能是查看共享试卷的其他用户记录)
|
||||
// 查询考试记录
|
||||
var record models.ExamRecord
|
||||
if err := db.Where("id = ?", recordID).
|
||||
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
|
||||
}
|
||||
|
||||
// 检查权限:只有以下情况可以查看
|
||||
// 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 表读取所有答案
|
||||
var userAnswers []models.ExamUserAnswer
|
||||
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")
|
||||
db := database.GetDB()
|
||||
|
||||
// 如果指定了试卷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
|
||||
}
|
||||
|
||||
// 查询试卷并检查权限
|
||||
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
|
||||
query = query.Where("exam_id = ?", examID)
|
||||
}
|
||||
|
||||
// 检查用户是否有权限访问该试卷
|
||||
if !exam.IsAccessibleBy(userID.(int64), db) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"success": false, "message": "无权限访问"})
|
||||
return
|
||||
}
|
||||
|
||||
// 查询该试卷的所有考试记录(包含用户信息)
|
||||
// 注意:包含 in_progress 状态的记录,以支持继续答题功能
|
||||
var records []models.ExamRecord
|
||||
if err := db.Where("exam_id = ? AND user_id = ?", examID, userID).
|
||||
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").
|
||||
if err := query.Preload("Exam").
|
||||
Order("created_at DESC").
|
||||
Find(&records).Error; err != nil {
|
||||
log.Printf("查询考试记录失败: %v", err)
|
||||
@ -735,15 +656,6 @@ func DeleteExam(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否为系统试卷
|
||||
if exam.IsSystem {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": "系统试卷不允许删除",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 软删除
|
||||
if err := db.Delete(&exam).Error; err != nil {
|
||||
log.Printf("删除试卷失败: %v", err)
|
||||
@ -954,22 +866,13 @@ func ShareExam(c *gin.Context) {
|
||||
|
||||
db := database.GetDB()
|
||||
|
||||
// 查询原始试卷,确认用户有权限分享
|
||||
var exam models.Exam
|
||||
if err := db.Where("id = ? AND user_id = ?", examID, userID).First(&exam).Error; err != nil {
|
||||
// 查询原始试卷
|
||||
var originalExam models.Exam
|
||||
if err := db.Where("id = ? AND user_id = ?", examID, userID).First(&originalExam).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "试卷不存在或无权限"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否为系统试卷
|
||||
if exam.IsSystem {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": "系统试卷不允许再次分享",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 开始事务
|
||||
tx := db.Begin()
|
||||
defer func() {
|
||||
@ -978,19 +881,19 @@ func ShareExam(c *gin.Context) {
|
||||
}
|
||||
}()
|
||||
|
||||
sharedByID := userID.(int64)
|
||||
sharedByID := uint(userID.(int64))
|
||||
sharedCount := 0
|
||||
now := time.Now()
|
||||
|
||||
// 为每个用户创建分享记录
|
||||
// 为每个用户创建分享副本
|
||||
for _, targetUserID := range req.UserIDs {
|
||||
// 检查是否已分享
|
||||
var existingShare models.ExamShare
|
||||
err := tx.Where("exam_id = ? AND shared_to_id = ?", uint(examID), int64(targetUserID)).
|
||||
First(&existingShare).Error
|
||||
// 检查是否已经分享给该用户
|
||||
var existingExam models.Exam
|
||||
err := tx.Where("user_id = ? AND shared_by_id = ? AND question_ids = ? AND deleted_at IS NULL",
|
||||
targetUserID, sharedByID, originalExam.QuestionIDs).First(&existingExam).Error
|
||||
|
||||
if err == nil {
|
||||
continue // 已存在,跳过
|
||||
// 已存在,跳过
|
||||
continue
|
||||
} else if err != gorm.ErrRecordNotFound {
|
||||
tx.Rollback()
|
||||
log.Printf("检查分享记录失败: %v", err)
|
||||
@ -998,20 +901,26 @@ func ShareExam(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 创建分享记录
|
||||
share := models.ExamShare{
|
||||
ExamID: uint(examID),
|
||||
SharedByID: sharedByID,
|
||||
SharedToID: int64(targetUserID),
|
||||
SharedAt: now,
|
||||
// 创建试卷副本
|
||||
sharedExam := models.Exam{
|
||||
UserID: targetUserID,
|
||||
Title: originalExam.Title,
|
||||
TotalScore: originalExam.TotalScore,
|
||||
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()
|
||||
log.Printf("创建分享记录失败: %v", err)
|
||||
log.Printf("创建分享试卷失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "分享失败"})
|
||||
return
|
||||
}
|
||||
|
||||
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),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -20,38 +20,11 @@ type Exam struct {
|
||||
PassScore int `gorm:"not null;default:60" json:"pass_score"` // 及格分数
|
||||
QuestionIDs datatypes.JSON `gorm:"type:json" json:"question_ids"` // 题目ID列表 (JSON数组)
|
||||
Status string `gorm:"type:varchar(20);not null;default:'active'" json:"status"` // 状态: active, archived
|
||||
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:"-"` // 该试卷的分享记录(作为被分享试卷)
|
||||
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
|
||||
// 关联
|
||||
SharedBy *User `gorm:"foreignKey:SharedByID" json:"shared_by,omitempty"` // 分享人信息
|
||||
}
|
||||
|
||||
// ExamRecord 考试记录
|
||||
|
||||
@ -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"
|
||||
}
|
||||
@ -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
55
main.go
@ -4,13 +4,11 @@ import (
|
||||
"ankao/internal/database"
|
||||
"ankao/internal/handlers"
|
||||
"ankao/internal/middleware"
|
||||
"ankao/internal/services"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@ -83,7 +81,6 @@ func main() {
|
||||
auth.GET("/exam-records/:record_id/answers", handlers.GetExamUserAnswers) // 获取用户答案
|
||||
auth.GET("/users/shareable", handlers.GetShareableUsers) // 获取可分享的用户列表
|
||||
auth.POST("/exams/:id/share", handlers.ShareExam) // 分享试卷
|
||||
auth.GET("/daily-exam/ranking", handlers.GetDailyExamRanking) // 获取每日一练排行榜
|
||||
}
|
||||
|
||||
// 题库管理API(需要管理员权限)
|
||||
@ -117,12 +114,6 @@ func main() {
|
||||
MaxHeaderBytes: 1 << 20, // 最大请求头:1MB
|
||||
}
|
||||
|
||||
// 启动定时任务
|
||||
startCronJobs()
|
||||
|
||||
// 应用启动时检测并生成今日每日一练
|
||||
go checkAndGenerateDailyExam()
|
||||
|
||||
log.Printf("服务器启动在端口 %s,超时配置:读/写 5分钟", port)
|
||||
|
||||
// 启动服务器
|
||||
@ -130,49 +121,3 @@ func main() {
|
||||
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("每日一练检测完成")
|
||||
}
|
||||
}
|
||||
|
||||
@ -94,24 +94,3 @@ export const getExam = (examId: number, showAnswer?: boolean) => {
|
||||
export const submitExam = (examId: number, data: SubmitExamRequest) => {
|
||||
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')
|
||||
}
|
||||
|
||||
@ -136,7 +136,7 @@
|
||||
|
||||
// 卡片内容样式
|
||||
.cardContent {
|
||||
padding: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.examInfo {
|
||||
@ -208,12 +208,6 @@
|
||||
width: 100%;
|
||||
justify-content: center; // 内容居中
|
||||
|
||||
// 覆盖 antd Tag 的默认图标间距
|
||||
:global(.anticon) + span,
|
||||
span + :global(.anticon) {
|
||||
margin-inline-start: 0 !important;
|
||||
}
|
||||
|
||||
.anticon {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
@ -236,12 +230,7 @@
|
||||
line-height: 1.4;
|
||||
width: 100%;
|
||||
justify-content: center; // 内容居中
|
||||
|
||||
// 覆盖 antd Tag 的默认图标间距
|
||||
:global(.anticon) + span,
|
||||
span + :global(.anticon) {
|
||||
margin-inline-start: 0 !important;
|
||||
}
|
||||
width: 100%;
|
||||
|
||||
.anticon {
|
||||
font-size: 14px;
|
||||
@ -364,6 +353,7 @@
|
||||
}
|
||||
|
||||
.cardContent {
|
||||
margin-top: 12px;
|
||||
|
||||
.infoRow {
|
||||
display: flex;
|
||||
@ -398,7 +388,6 @@
|
||||
// 旧版兼容样式 - divider已合并,不再重复定义
|
||||
|
||||
// 响应式适配
|
||||
// 移动端:1列
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 16px;
|
||||
@ -406,7 +395,7 @@
|
||||
}
|
||||
|
||||
.examGrid {
|
||||
grid-template-columns: 1fr; // 移动端显示1列
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@ -483,7 +472,7 @@
|
||||
|
||||
.examStats {
|
||||
padding: 12px 0;
|
||||
gap: 6px;
|
||||
gap: 10px;
|
||||
|
||||
.statItem {
|
||||
.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) {
|
||||
.container {
|
||||
padding: 12px;
|
||||
|
||||
@ -30,9 +30,7 @@ import {
|
||||
PrinterOutlined,
|
||||
ArrowLeftOutlined,
|
||||
ShareAltOutlined,
|
||||
UserOutlined,
|
||||
TeamOutlined,
|
||||
CrownOutlined
|
||||
UserOutlined
|
||||
} from '@ant-design/icons'
|
||||
import * as examApi from '../api/exam'
|
||||
import styles from './ExamManagement.module.less'
|
||||
@ -48,10 +46,8 @@ interface ExamListItem {
|
||||
best_score: number
|
||||
has_in_progress_exam: boolean
|
||||
in_progress_record_id?: number
|
||||
participant_count: number // 共享试卷的参与人数
|
||||
created_at: string
|
||||
is_shared?: boolean
|
||||
is_system?: boolean // 是否为系统试卷
|
||||
shared_by?: {
|
||||
id: number
|
||||
username: string
|
||||
@ -301,19 +297,10 @@ const ExamManagement: React.FC = () => {
|
||||
<div className={styles.coverInfo}>
|
||||
<h3
|
||||
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}
|
||||
</h3>
|
||||
{exam.is_system && (
|
||||
<Tag
|
||||
icon={<CrownOutlined />}
|
||||
color="orange"
|
||||
className={styles.shareTag}
|
||||
>
|
||||
系统
|
||||
</Tag>
|
||||
)}
|
||||
{exam.is_shared && exam.shared_by && (
|
||||
<Tag
|
||||
icon={<ShareAltOutlined />}
|
||||
@ -335,8 +322,6 @@ const ExamManagement: React.FC = () => {
|
||||
>
|
||||
{exam.has_in_progress_exam ? '继续' : '考试'}
|
||||
</Button>,
|
||||
// 只有非系统且自己创建的试卷才能分享
|
||||
!exam.is_system && !exam.is_shared && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ShareAltOutlined />}
|
||||
@ -344,8 +329,7 @@ const ExamManagement: React.FC = () => {
|
||||
className={styles.actionButton}
|
||||
>
|
||||
分享
|
||||
</Button>
|
||||
),
|
||||
</Button>,
|
||||
<Button
|
||||
type="text"
|
||||
icon={<HistoryOutlined />}
|
||||
@ -370,8 +354,6 @@ const ExamManagement: React.FC = () => {
|
||||
>
|
||||
打印
|
||||
</Button>,
|
||||
// 只有非系统且自己创建的试卷才能删除
|
||||
!exam.is_system && !exam.is_shared && (
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
@ -381,10 +363,25 @@ const ExamManagement: React.FC = () => {
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
)
|
||||
].filter(Boolean)}
|
||||
]}
|
||||
>
|
||||
<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.statItem}>
|
||||
@ -399,14 +396,6 @@ const ExamManagement: React.FC = () => {
|
||||
<span>考试次数 {exam.attempt_count}</span>
|
||||
</Tag>
|
||||
</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 && (
|
||||
<div className={styles.statItem}>
|
||||
<Tag color="processing" className={styles.progressTag}>
|
||||
@ -444,14 +433,6 @@ const ExamManagement: React.FC = () => {
|
||||
style={{ marginBottom: 16 }}
|
||||
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.Item label="状态">
|
||||
{record.status === 'in_progress' && <Tag color="processing">进行中</Tag>}
|
||||
@ -466,14 +447,8 @@ const ExamManagement: React.FC = () => {
|
||||
{record.status === 'in_progress' ? (
|
||||
<span>-</span>
|
||||
) : (
|
||||
<span style={{
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: record.is_passed ? '#52c41a' : '#ff4d4f',
|
||||
lineHeight: 1,
|
||||
verticalAlign: 'middle'
|
||||
}}>
|
||||
{record.score} 分
|
||||
<span style={{ fontSize: 18, fontWeight: 'bold', color: record.is_passed ? '#52c41a' : '#ff4d4f' }}>
|
||||
{record.score} / {record.total_score}
|
||||
</span>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
@ -511,6 +486,18 @@ const ExamManagement: React.FC = () => {
|
||||
查看详情
|
||||
</Button>
|
||||
)}
|
||||
{record.status === 'graded' && (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={() => {
|
||||
setRecordsDrawerVisible(false)
|
||||
navigate(`/exam/result/${record.id}`)
|
||||
}}
|
||||
>
|
||||
查看答案
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@ -205,7 +205,7 @@
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
// A4纸张设置
|
||||
// A4纸张设置 - 增加边距以确保圆角不被遮挡
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 1cm;
|
||||
@ -225,9 +225,6 @@
|
||||
.paperHeader {
|
||||
margin-bottom: 6px;
|
||||
padding-bottom: 4px;
|
||||
// 防止试卷标题和考试说明分页
|
||||
page-break-after: avoid;
|
||||
page-break-inside: avoid;
|
||||
|
||||
.paperTitle {
|
||||
font-size: 16pt !important;
|
||||
@ -259,9 +256,6 @@
|
||||
overflow: visible;
|
||||
// 添加一些内边距确保圆角有空间显示
|
||||
padding: 2px;
|
||||
// 防止考试说明和填空题分页
|
||||
page-break-after: avoid;
|
||||
page-break-inside: avoid;
|
||||
|
||||
:global(.ant-card-body) {
|
||||
padding: 6px;
|
||||
@ -286,18 +280,10 @@
|
||||
|
||||
.questionGroup {
|
||||
margin-bottom: 8px;
|
||||
// 防止题型组内部分页
|
||||
page-break-inside: avoid;
|
||||
// 尽量让下一个题型紧接着显示
|
||||
page-break-after: avoid;
|
||||
|
||||
.groupHeader {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
margin-bottom: 4px;
|
||||
padding-bottom: 2px;
|
||||
// 确保题型标题和第一道题在同一页
|
||||
page-break-after: avoid;
|
||||
|
||||
.groupTitle {
|
||||
font-size: 12pt;
|
||||
@ -305,15 +291,12 @@
|
||||
|
||||
.groupScore {
|
||||
font-size: 10pt;
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.questionItem {
|
||||
margin-bottom: 6px;
|
||||
// 防止题目内部分页,保持题目完整性
|
||||
page-break-inside: avoid;
|
||||
|
||||
.questionContent {
|
||||
margin-bottom: 3px;
|
||||
|
||||
@ -42,12 +42,12 @@ const TYPE_NAME: Record<string, string> = {
|
||||
// 题型分值映射
|
||||
const TYPE_SCORE: Record<string, number> = {
|
||||
'fill-in-blank': 2.0,
|
||||
'true-false': 1.0,
|
||||
'true-false': 2.0,
|
||||
'multiple-choice': 1.0,
|
||||
'multiple-selection': 2.0,
|
||||
'multiple-selection': 2.5,
|
||||
'short-answer': 0, // 不计分
|
||||
'ordinary-essay': 10.0,
|
||||
'management-essay': 10.0,
|
||||
'ordinary-essay': 4.5,
|
||||
'management-essay': 4.5,
|
||||
}
|
||||
|
||||
const ExamPrint: React.FC = () => {
|
||||
@ -352,14 +352,14 @@ const ExamPrint: React.FC = () => {
|
||||
return (
|
||||
<div key={type} className={styles.questionGroup}>
|
||||
<div className={styles.groupHeader}>
|
||||
<span className={styles.groupTitle}>
|
||||
<Text className={styles.groupTitle}>
|
||||
{TYPE_NAME[type]}
|
||||
</Text>
|
||||
{TYPE_SCORE[type] > 0 && (
|
||||
<span className={styles.groupScore} style={{ marginLeft: '8px' }}>
|
||||
<Text type="secondary" className={styles.groupScore}>
|
||||
(共{questions.length}题,每题{TYPE_SCORE[type]}分,共{totalScore}分)
|
||||
</span>
|
||||
</Text>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.questionsList}>
|
||||
{questions.map((question, index) => {
|
||||
@ -448,8 +448,8 @@ const ExamPrint: React.FC = () => {
|
||||
<Title level={4}>考试说明</Title>
|
||||
<ul>
|
||||
<li>本试卷满分100分,考试时间为60分钟</li>
|
||||
<li>简答题请在答题区域内作答,字迹清晰工整</li>
|
||||
<li>论述题根据自己的职务类型从以下2道题目中选答1道</li>
|
||||
<li>简答题每题8分,请在答题区域内作答,字迹清晰工整</li>
|
||||
<li>论述题每题9分,从以下2道题目中任选1道作答</li>
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
@ -464,12 +464,12 @@ const ExamPrint: React.FC = () => {
|
||||
{essayQuestions.length > 0 && (
|
||||
<div className={styles.questionGroup}>
|
||||
<div className={styles.groupHeader}>
|
||||
<span className={styles.groupTitle}>
|
||||
<Text className={styles.groupTitle}>
|
||||
{TYPE_NAME['ordinary-essay']}
|
||||
<span className={styles.groupScore} style={{ marginLeft: '8px' }}>
|
||||
(根据自己的职务,在以下2道论述题选择1道作答,共10分)
|
||||
</span>
|
||||
</span>
|
||||
</Text>
|
||||
<Text type="secondary" className={styles.groupScore}>
|
||||
(以下2道论述题任选1道作答,共9分)
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.questionsList}>
|
||||
{essayQuestions.map((question, index) => renderEssay(question, index))}
|
||||
|
||||
@ -348,16 +348,6 @@ const ExamResultNew: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* 顶部返回按钮 */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Button
|
||||
icon={<LeftOutlined />}
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
返回
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 成绩展示 */}
|
||||
<Result
|
||||
status={isPassed ? "success" : "warning"}
|
||||
@ -369,29 +359,6 @@ const ExamResultNew: React.FC = () => {
|
||||
</Text>
|
||||
</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>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -24,7 +24,6 @@ import {
|
||||
CrownOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import * as questionApi from '../api/question'
|
||||
import * as examApi from '../api/exam'
|
||||
import { fetchWithAuth } from '../utils/request'
|
||||
import type { Statistics } from '../types/question'
|
||||
import styles from './Home.module.less'
|
||||
@ -108,25 +107,8 @@ const Home: React.FC = () => {
|
||||
const [dailyRanking, setDailyRanking] = useState<questionApi.UserStats[]>([])
|
||||
const [totalRanking, setTotalRanking] = useState<questionApi.UserStats[]>([])
|
||||
const [rankingLoading, setRankingLoading] = useState(false)
|
||||
const [rankingType, setRankingType] = useState<'daily-exam' | 'daily' | 'total'>('daily-exam') // 排行榜类型:每日一练、每日或总榜
|
||||
const [sliderPosition, setSliderPosition] = useState<'left' | 'center' | '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 [rankingType, setRankingType] = useState<'daily' | 'total'>('daily') // 排行榜类型:每日或总榜
|
||||
const [sliderPosition, setSliderPosition] = useState<'left' | 'right'>('left') // 滑块位置
|
||||
|
||||
// 答题设置状态
|
||||
const [autoNext, setAutoNext] = useState(() => {
|
||||
@ -144,12 +126,6 @@ const Home: React.FC = () => {
|
||||
return saved !== null ? saved === 'true' : false
|
||||
})
|
||||
|
||||
// 公告显示状态
|
||||
const [showAnnouncement, setShowAnnouncement] = useState(() => {
|
||||
const dismissed = localStorage.getItem('announcementDismissed')
|
||||
return dismissed !== 'true'
|
||||
})
|
||||
|
||||
// 加载统计数据
|
||||
const loadStatistics = async () => {
|
||||
try {
|
||||
@ -194,9 +170,7 @@ const Home: React.FC = () => {
|
||||
|
||||
// 加载当前选中的排行榜数据
|
||||
const loadCurrentRanking = async () => {
|
||||
if (rankingType === 'daily-exam') {
|
||||
await loadDailyExamRanking()
|
||||
} else if (rankingType === 'daily') {
|
||||
if (rankingType === 'daily') {
|
||||
await loadDailyRanking()
|
||||
} else {
|
||||
await loadTotalRanking()
|
||||
@ -204,32 +178,9 @@ const Home: React.FC = () => {
|
||||
}
|
||||
|
||||
// 切换排行榜类型
|
||||
const switchRankingType = (type: 'daily-exam' | 'daily' | 'total') => {
|
||||
const switchRankingType = (type: 'daily' | 'total') => {
|
||||
setRankingType(type)
|
||||
if (type === 'daily-exam') {
|
||||
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)
|
||||
}
|
||||
setSliderPosition(type === 'daily' ? 'left' : 'right')
|
||||
}
|
||||
|
||||
// 加载用户信息
|
||||
@ -410,12 +361,6 @@ const Home: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭公告
|
||||
const handleCloseAnnouncement = () => {
|
||||
setShowAnnouncement(false)
|
||||
localStorage.setItem('announcementDismissed', 'true')
|
||||
}
|
||||
|
||||
// 获取用户类型显示文本
|
||||
const getUserTypeText = (type?: string) => {
|
||||
if (!type) return '未设置'
|
||||
@ -572,28 +517,6 @@ const Home: React.FC = () => {
|
||||
)}
|
||||
</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}>
|
||||
<Title level={4} className={styles.sectionTitle}>
|
||||
@ -638,67 +561,6 @@ const Home: React.FC = () => {
|
||||
<RocketOutlined /> 快速开始
|
||||
</Title>
|
||||
<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}>
|
||||
<Card
|
||||
hoverable
|
||||
@ -832,12 +694,6 @@ const Home: React.FC = () => {
|
||||
<TrophyOutlined /> 排行榜
|
||||
</Title>
|
||||
<div className={styles.rankingSwitch}>
|
||||
<div
|
||||
className={`${styles.rankingSwitchButton} ${rankingType === 'daily-exam' ? styles.active : ''}`}
|
||||
onClick={() => switchRankingType('daily-exam')}
|
||||
>
|
||||
每日一练
|
||||
</div>
|
||||
<div
|
||||
className={`${styles.rankingSwitchButton} ${rankingType === 'daily' ? styles.active : ''}`}
|
||||
onClick={() => switchRankingType('daily')}
|
||||
@ -853,87 +709,13 @@ const Home: React.FC = () => {
|
||||
<div
|
||||
className={styles.rankingSwitchSlider}
|
||||
style={{
|
||||
width: 'calc(33.33% - 4px)',
|
||||
left: sliderPosition === 'left' ? '4px' : sliderPosition === 'center' ? 'calc(33.33% + 0px)' : 'calc(66.66% - 4px)',
|
||||
width: 'calc(50% - 4px)',
|
||||
left: sliderPosition === 'left' ? '4px' : 'calc(50% + 0px)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{(rankingLoading || dailyExamLoading) ? (
|
||||
{rankingLoading ? (
|
||||
<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' ? (
|
||||
dailyRanking.length === 0 ? (
|
||||
<Card className={styles.rankingCard}>
|
||||
|
||||
@ -61,12 +61,6 @@ export interface ExamRecord {
|
||||
status: 'in_progress' | 'submitted' | 'graded'
|
||||
is_passed: boolean
|
||||
exam?: ExamModel
|
||||
user?: { // 用户信息(共享试卷时返回)
|
||||
id: number
|
||||
username: string
|
||||
nickname: string
|
||||
avatar: string
|
||||
}
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@ -104,14 +98,6 @@ export type ExamListResponse = Array<{
|
||||
best_score: number
|
||||
has_in_progress_exam: boolean
|
||||
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
|
||||
}>
|
||||
|
||||
|
||||
@ -31,7 +31,7 @@ export default defineConfig({
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8080',
|
||||
target: 'https://ankao.yuchat.top',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user