Compare commits
18 Commits
4f95514af8
...
f9a5e06df2
| Author | SHA1 | Date | |
|---|---|---|---|
| f9a5e06df2 | |||
| d04de0190c | |||
| 2d778364e2 | |||
| 0074e5978f | |||
| 2be5f49528 | |||
| 4f7dfae855 | |||
| fa2964e144 | |||
| b1551e6deb | |||
| 960f557ca4 | |||
| a77242c844 | |||
| e3e0671204 | |||
| 023ab1cc55 | |||
| 8d10ebc327 | |||
| 62281b5047 | |||
| 2cc0c154dc | |||
| ccc77beef8 | |||
| 03f3e14f6e | |||
| 3704d52a26 |
38
cmd/cleanup/main.go
Normal file
38
cmd/cleanup/main.go
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
189
cmd/migrate_exam_shares.go
Normal file
189
cmd/migrate_exam_shares.go
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
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,6 +41,7 @@ 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
2
go.sum
@ -74,6 +74,8 @@ 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=
|
||||||
|
|||||||
@ -38,6 +38,7 @@ 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{}, // 用户答案表
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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,
|
"fill-in-blank": 2.0, // 填空题每题2分
|
||||||
"true-false": 2.0,
|
"true-false": 1.0, // 判断题每题1分
|
||||||
"multiple-choice": 1.0,
|
"multiple-choice": 1.0, // 单选题每题1分
|
||||||
"multiple-selection": 2.5,
|
"multiple-selection": 2.0, // 多选题每题2分
|
||||||
"short-answer": 8.0, // 简答题 8 分
|
"short-answer": 10.0, // 简答题10分
|
||||||
"ordinary-essay": 9.0, // 论述题 9 分
|
"ordinary-essay": 10.0, // 论述题10分
|
||||||
"management-essay": 9.0, // 论述题 9 分
|
"management-essay": 10.0, // 论述题10分
|
||||||
}
|
}
|
||||||
|
|
||||||
// 评分
|
// 评分
|
||||||
|
|||||||
@ -46,13 +46,13 @@ func CreateExam(c *gin.Context) {
|
|||||||
|
|
||||||
// 使用固定的题型配置(总分100分)
|
// 使用固定的题型配置(总分100分)
|
||||||
questionTypes := []models.QuestionTypeConfig{
|
questionTypes := []models.QuestionTypeConfig{
|
||||||
{Type: "fill-in-blank", Count: 10, Score: 2.0}, // 20分
|
{Type: "fill-in-blank", Count: 20, Score: 2.0}, // 40分
|
||||||
{Type: "true-false", Count: 10, Score: 2.0}, // 20分
|
{Type: "true-false", Count: 10, Score: 1.0}, // 10分
|
||||||
{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.5}, // 25分
|
{Type: "multiple-selection", Count: 10, Score: 2.0}, // 20分
|
||||||
{Type: "short-answer", Count: 2, Score: 10.0}, // 20分
|
{Type: "short-answer", Count: 1, Score: 10.0}, // 10分
|
||||||
{Type: "ordinary-essay", Count: 1, Score: 4.5}, // 4.5分(普通涉密人员论述题)
|
{Type: "ordinary-essay", Count: 1, Score: 10.0}, // 10分(普通涉密人员论述题)
|
||||||
{Type: "management-essay", Count: 1, Score: 4.5}, // 4.5分(保密管理人员论述题)
|
{Type: "management-essay", Count: 1, Score: 10.0}, // 10分(保密管理人员论述题)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按题型配置随机抽取题目
|
// 按题型配置随机抽取题目
|
||||||
@ -152,62 +152,91 @@ func GetExamList(c *gin.Context) {
|
|||||||
|
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
|
|
||||||
// 查询用户创建的试卷(包括被分享的试卷)
|
// 获取用户可访问的试卷(拥有的 + 被分享的)
|
||||||
var exams []models.Exam
|
exams, err := models.GetAccessibleExams(userID.(int64), db)
|
||||||
if err := db.Where("user_id = ? AND status = ?", userID, "active").
|
if err != nil {
|
||||||
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 {
|
||||||
stats := ExamWithStats{
|
continue
|
||||||
Exam: exam,
|
|
||||||
QuestionCount: len(questionIDs),
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查询该试卷的考试记录统计
|
|
||||||
var count int64
|
|
||||||
db.Model(&models.ExamRecord{}).Where("exam_id = ? AND user_id = ?", exam.ID, userID).Count(&count)
|
|
||||||
stats.AttemptCount = int(count)
|
|
||||||
|
|
||||||
// 查询最高分
|
|
||||||
var record models.ExamRecord
|
|
||||||
if err := db.Where("exam_id = ? AND user_id = ?", exam.ID, userID).
|
|
||||||
Order("score DESC").
|
|
||||||
First(&record).Error; err == nil {
|
|
||||||
stats.BestScore = record.Score
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查询是否有进行中的考试(status为in_progress)
|
|
||||||
var inProgressRecord models.ExamRecord
|
|
||||||
if err := db.Where("exam_id = ? AND user_id = ? AND status = ?", exam.ID, userID, "in_progress").
|
|
||||||
Order("created_at DESC").
|
|
||||||
First(&inProgressRecord).Error; err == nil {
|
|
||||||
stats.HasInProgressExam = true
|
|
||||||
stats.InProgressRecordID = inProgressRecord.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
result = append(result, stats)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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").
|
||||||
|
First(&record).Error; err == nil {
|
||||||
|
stats.BestScore = record.Score
|
||||||
|
}
|
||||||
|
|
||||||
|
var inProgressRecord models.ExamRecord
|
||||||
|
if err := db.Where("exam_id = ? AND user_id = ? AND status = ?", exam.ID, userID, "in_progress").
|
||||||
|
Order("created_at DESC").
|
||||||
|
First(&inProgressRecord).Error; err == nil {
|
||||||
|
stats.HasInProgressExam = true
|
||||||
|
stats.InProgressRecordID = inProgressRecord.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// 参与人数统计(简化:直接统计该试卷的不同用户)
|
||||||
|
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{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@ -479,15 +508,32 @@ func GetExamRecord(c *gin.Context) {
|
|||||||
|
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
|
|
||||||
// 查询考试记录
|
// 查询考试记录(不限制用户,因为可能是查看共享试卷的其他用户记录)
|
||||||
var record models.ExamRecord
|
var record models.ExamRecord
|
||||||
if err := db.Where("id = ? AND user_id = ?", recordID, userID).
|
if err := db.Where("id = ?", recordID).
|
||||||
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 {
|
||||||
@ -603,22 +649,55 @@ func GetExamRecordList(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
examIDStr := c.Query("exam_id")
|
examIDStr := c.Query("exam_id")
|
||||||
|
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
query := db.Where("user_id = ?", userID)
|
|
||||||
|
|
||||||
// 如果指定了试卷ID,只查询该试卷的记录
|
// 如果指定了试卷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
|
||||||
|
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
|
var records []models.ExamRecord
|
||||||
if err := query.Preload("Exam").
|
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)
|
||||||
@ -656,6 +735,15 @@ 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)
|
||||||
@ -866,13 +954,22 @@ func ShareExam(c *gin.Context) {
|
|||||||
|
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
|
|
||||||
// 查询原始试卷
|
// 查询原始试卷,确认用户有权限分享
|
||||||
var originalExam models.Exam
|
var exam models.Exam
|
||||||
if err := db.Where("id = ? AND user_id = ?", examID, userID).First(&originalExam).Error; err != nil {
|
if err := db.Where("id = ? AND user_id = ?", examID, userID).First(&exam).Error; err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "试卷不存在或无权限"})
|
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "试卷不存在或无权限"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查是否为系统试卷
|
||||||
|
if exam.IsSystem {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "系统试卷不允许再次分享",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 开始事务
|
// 开始事务
|
||||||
tx := db.Begin()
|
tx := db.Begin()
|
||||||
defer func() {
|
defer func() {
|
||||||
@ -881,19 +978,19 @@ func ShareExam(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
sharedByID := uint(userID.(int64))
|
sharedByID := userID.(int64)
|
||||||
sharedCount := 0
|
sharedCount := 0
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
// 为每个用户创建分享副本
|
// 为每个用户创建分享记录
|
||||||
for _, targetUserID := range req.UserIDs {
|
for _, targetUserID := range req.UserIDs {
|
||||||
// 检查是否已经分享给该用户
|
// 检查是否已分享
|
||||||
var existingExam models.Exam
|
var existingShare models.ExamShare
|
||||||
err := tx.Where("user_id = ? AND shared_by_id = ? AND question_ids = ? AND deleted_at IS NULL",
|
err := tx.Where("exam_id = ? AND shared_to_id = ?", uint(examID), int64(targetUserID)).
|
||||||
targetUserID, sharedByID, originalExam.QuestionIDs).First(&existingExam).Error
|
First(&existingShare).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)
|
||||||
@ -901,26 +998,20 @@ func ShareExam(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建试卷副本
|
// 创建分享记录
|
||||||
sharedExam := models.Exam{
|
share := models.ExamShare{
|
||||||
UserID: targetUserID,
|
ExamID: uint(examID),
|
||||||
Title: originalExam.Title,
|
SharedByID: sharedByID,
|
||||||
TotalScore: originalExam.TotalScore,
|
SharedToID: int64(targetUserID),
|
||||||
Duration: originalExam.Duration,
|
SharedAt: now,
|
||||||
PassScore: originalExam.PassScore,
|
|
||||||
QuestionIDs: originalExam.QuestionIDs,
|
|
||||||
Status: "active",
|
|
||||||
IsShared: true,
|
|
||||||
SharedByID: &sharedByID,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Create(&sharedExam).Error; err != nil {
|
if err := tx.Create(&share).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++
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -939,3 +1030,84 @@ 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,11 +20,38 @@ 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
|
||||||
IsShared bool `gorm:"default:false" json:"is_shared"` // 是否为分享的试卷
|
IsSystem bool `gorm:"default:false;index" json:"is_system"` // 是否为系统试卷
|
||||||
SharedByID *uint `gorm:"index" json:"shared_by_id,omitempty"` // 分享人ID (如果是分享的试卷)
|
|
||||||
|
|
||||||
// 关联
|
// 关联关系
|
||||||
SharedBy *User `gorm:"foreignKey:SharedByID" json:"shared_by,omitempty"` // 分享人信息
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExamRecord 考试记录
|
// ExamRecord 考试记录
|
||||||
|
|||||||
30
internal/models/exam_share.go
Normal file
30
internal/models/exam_share.go
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
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"
|
||||||
|
}
|
||||||
201
internal/services/daily_exam_service.go
Normal file
201
internal/services/daily_exam_service.go
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
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,11 +4,13 @@ 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() {
|
||||||
@ -81,6 +83,7 @@ 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(需要管理员权限)
|
||||||
@ -114,6 +117,12 @@ func main() {
|
|||||||
MaxHeaderBytes: 1 << 20, // 最大请求头:1MB
|
MaxHeaderBytes: 1 << 20, // 最大请求头:1MB
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 启动定时任务
|
||||||
|
startCronJobs()
|
||||||
|
|
||||||
|
// 应用启动时检测并生成今日每日一练
|
||||||
|
go checkAndGenerateDailyExam()
|
||||||
|
|
||||||
log.Printf("服务器启动在端口 %s,超时配置:读/写 5分钟", port)
|
log.Printf("服务器启动在端口 %s,超时配置:读/写 5分钟", port)
|
||||||
|
|
||||||
// 启动服务器
|
// 启动服务器
|
||||||
@ -121,3 +130,49 @@ 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("每日一练检测完成")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -94,3 +94,24 @@ 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')
|
||||||
|
}
|
||||||
|
|||||||
@ -136,7 +136,7 @@
|
|||||||
|
|
||||||
// 卡片内容样式
|
// 卡片内容样式
|
||||||
.cardContent {
|
.cardContent {
|
||||||
padding: 16px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.examInfo {
|
.examInfo {
|
||||||
@ -208,6 +208,12 @@
|
|||||||
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;
|
||||||
@ -230,7 +236,12 @@
|
|||||||
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;
|
||||||
@ -353,7 +364,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cardContent {
|
.cardContent {
|
||||||
margin-top: 12px;
|
|
||||||
|
|
||||||
.infoRow {
|
.infoRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -388,6 +398,7 @@
|
|||||||
// 旧版兼容样式 - divider已合并,不再重复定义
|
// 旧版兼容样式 - divider已合并,不再重复定义
|
||||||
|
|
||||||
// 响应式适配
|
// 响应式适配
|
||||||
|
// 移动端:1列
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.container {
|
.container {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
@ -395,7 +406,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.examGrid {
|
.examGrid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr; // 移动端显示1列
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -472,7 +483,7 @@
|
|||||||
|
|
||||||
.examStats {
|
.examStats {
|
||||||
padding: 12px 0;
|
padding: 12px 0;
|
||||||
gap: 10px;
|
gap: 6px;
|
||||||
|
|
||||||
.statItem {
|
.statItem {
|
||||||
.valueTag {
|
.valueTag {
|
||||||
@ -571,6 +582,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 平板端: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;
|
||||||
|
|||||||
@ -30,7 +30,9 @@ 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'
|
||||||
@ -46,8 +48,10 @@ 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
|
||||||
@ -297,10 +301,19 @@ const ExamManagement: React.FC = () => {
|
|||||||
<div className={styles.coverInfo}>
|
<div className={styles.coverInfo}>
|
||||||
<h3
|
<h3
|
||||||
className={styles.examTitle}
|
className={styles.examTitle}
|
||||||
style={{ marginBottom: exam.is_shared && exam.shared_by ? '12px' : '0' }}
|
style={{ marginBottom: (exam.is_system || (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 />}
|
||||||
@ -322,14 +335,17 @@ const ExamManagement: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{exam.has_in_progress_exam ? '继续' : '考试'}
|
{exam.has_in_progress_exam ? '继续' : '考试'}
|
||||||
</Button>,
|
</Button>,
|
||||||
<Button
|
// 只有非系统且自己创建的试卷才能分享
|
||||||
type="text"
|
!exam.is_system && !exam.is_shared && (
|
||||||
icon={<ShareAltOutlined />}
|
<Button
|
||||||
onClick={() => handleOpenShareModal(exam.id)}
|
type="text"
|
||||||
className={styles.actionButton}
|
icon={<ShareAltOutlined />}
|
||||||
>
|
onClick={() => handleOpenShareModal(exam.id)}
|
||||||
分享
|
className={styles.actionButton}
|
||||||
</Button>,
|
>
|
||||||
|
分享
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
icon={<HistoryOutlined />}
|
icon={<HistoryOutlined />}
|
||||||
@ -354,34 +370,21 @@ const ExamManagement: React.FC = () => {
|
|||||||
>
|
>
|
||||||
打印
|
打印
|
||||||
</Button>,
|
</Button>,
|
||||||
<Button
|
// 只有非系统且自己创建的试卷才能删除
|
||||||
type="text"
|
!exam.is_system && !exam.is_shared && (
|
||||||
danger
|
<Button
|
||||||
icon={<DeleteOutlined />}
|
type="text"
|
||||||
onClick={() => handleDeleteExam(exam.id)}
|
danger
|
||||||
className={styles.actionButton}
|
icon={<DeleteOutlined />}
|
||||||
>
|
onClick={() => handleDeleteExam(exam.id)}
|
||||||
删除
|
className={styles.actionButton}
|
||||||
</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}>
|
||||||
@ -396,6 +399,14 @@ 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}>
|
||||||
@ -433,6 +444,14 @@ 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>}
|
||||||
@ -447,8 +466,14 @@ const ExamManagement: React.FC = () => {
|
|||||||
{record.status === 'in_progress' ? (
|
{record.status === 'in_progress' ? (
|
||||||
<span>-</span>
|
<span>-</span>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ fontSize: 18, fontWeight: 'bold', color: record.is_passed ? '#52c41a' : '#ff4d4f' }}>
|
<span style={{
|
||||||
{record.score} / {record.total_score}
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: record.is_passed ? '#52c41a' : '#ff4d4f',
|
||||||
|
lineHeight: 1,
|
||||||
|
verticalAlign: 'middle'
|
||||||
|
}}>
|
||||||
|
{record.score} 分
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
@ -486,18 +511,6 @@ 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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -205,7 +205,7 @@
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A4纸张设置 - 增加边距以确保圆角不被遮挡
|
// A4纸张设置
|
||||||
@page {
|
@page {
|
||||||
size: A4;
|
size: A4;
|
||||||
margin: 1cm;
|
margin: 1cm;
|
||||||
@ -225,6 +225,9 @@
|
|||||||
.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;
|
||||||
@ -256,6 +259,9 @@
|
|||||||
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;
|
||||||
@ -280,10 +286,18 @@
|
|||||||
|
|
||||||
.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;
|
||||||
@ -291,12 +305,15 @@
|
|||||||
|
|
||||||
.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;
|
||||||
|
|||||||
@ -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': 2.0,
|
'true-false': 1.0,
|
||||||
'multiple-choice': 1.0,
|
'multiple-choice': 1.0,
|
||||||
'multiple-selection': 2.5,
|
'multiple-selection': 2.0,
|
||||||
'short-answer': 0, // 不计分
|
'short-answer': 0, // 不计分
|
||||||
'ordinary-essay': 4.5,
|
'ordinary-essay': 10.0,
|
||||||
'management-essay': 4.5,
|
'management-essay': 10.0,
|
||||||
}
|
}
|
||||||
|
|
||||||
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}>
|
||||||
<Text className={styles.groupTitle}>
|
<span 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>本试卷满分100分,考试时间为60分钟</li>
|
<li>本试卷满分100分,考试时间为60分钟</li>
|
||||||
<li>简答题每题8分,请在答题区域内作答,字迹清晰工整</li>
|
<li>简答题请在答题区域内作答,字迹清晰工整</li>
|
||||||
<li>论述题每题9分,从以下2道题目中任选1道作答</li>
|
<li>论述题根据自己的职务类型从以下2道题目中选答1道</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}>
|
||||||
<Text className={styles.groupTitle}>
|
<span className={styles.groupTitle}>
|
||||||
{TYPE_NAME['ordinary-essay']}
|
{TYPE_NAME['ordinary-essay']}
|
||||||
</Text>
|
<span className={styles.groupScore} style={{ marginLeft: '8px' }}>
|
||||||
<Text type="secondary" className={styles.groupScore}>
|
(根据自己的职务,在以下2道论述题选择1道作答,共10分)
|
||||||
(以下2道论述题任选1道作答,共9分)
|
</span>
|
||||||
</Text>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.questionsList}>
|
<div className={styles.questionsList}>
|
||||||
{essayQuestions.map((question, index) => renderEssay(question, index))}
|
{essayQuestions.map((question, index) => renderEssay(question, index))}
|
||||||
|
|||||||
@ -348,6 +348,16 @@ 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"}
|
||||||
@ -359,6 +369,29 @@ 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
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 成绩统计 */}
|
{/* 成绩统计 */}
|
||||||
@ -548,39 +581,6 @@ 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -24,6 +24,7 @@ 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'
|
||||||
@ -107,8 +108,25 @@ 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' | 'total'>('daily') // 排行榜类型:每日或总榜
|
const [rankingType, setRankingType] = useState<'daily-exam' | 'daily' | 'total'>('daily-exam') // 排行榜类型:每日一练、每日或总榜
|
||||||
const [sliderPosition, setSliderPosition] = useState<'left' | 'right'>('left') // 滑块位置
|
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 [autoNext, setAutoNext] = useState(() => {
|
const [autoNext, setAutoNext] = useState(() => {
|
||||||
@ -126,6 +144,12 @@ 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 {
|
||||||
@ -170,7 +194,9 @@ const Home: React.FC = () => {
|
|||||||
|
|
||||||
// 加载当前选中的排行榜数据
|
// 加载当前选中的排行榜数据
|
||||||
const loadCurrentRanking = async () => {
|
const loadCurrentRanking = async () => {
|
||||||
if (rankingType === 'daily') {
|
if (rankingType === 'daily-exam') {
|
||||||
|
await loadDailyExamRanking()
|
||||||
|
} else if (rankingType === 'daily') {
|
||||||
await loadDailyRanking()
|
await loadDailyRanking()
|
||||||
} else {
|
} else {
|
||||||
await loadTotalRanking()
|
await loadTotalRanking()
|
||||||
@ -178,9 +204,32 @@ const Home: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 切换排行榜类型
|
// 切换排行榜类型
|
||||||
const switchRankingType = (type: 'daily' | 'total') => {
|
const switchRankingType = (type: 'daily-exam' | 'daily' | 'total') => {
|
||||||
setRankingType(type)
|
setRankingType(type)
|
||||||
setSliderPosition(type === 'daily' ? 'left' : 'right')
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载用户信息
|
// 加载用户信息
|
||||||
@ -361,6 +410,12 @@ 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 '未设置'
|
||||||
@ -517,6 +572,28 @@ 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}>
|
||||||
@ -561,6 +638,67 @@ 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
|
||||||
@ -694,6 +832,12 @@ 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')}
|
||||||
@ -709,13 +853,87 @@ const Home: React.FC = () => {
|
|||||||
<div
|
<div
|
||||||
className={styles.rankingSwitchSlider}
|
className={styles.rankingSwitchSlider}
|
||||||
style={{
|
style={{
|
||||||
width: 'calc(50% - 4px)',
|
width: 'calc(33.33% - 4px)',
|
||||||
left: sliderPosition === 'left' ? '4px' : 'calc(50% + 0px)',
|
left: sliderPosition === 'left' ? '4px' : sliderPosition === 'center' ? 'calc(33.33% + 0px)' : 'calc(66.66% - 4px)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{rankingLoading ? (
|
{(rankingLoading || dailyExamLoading) ? (
|
||||||
<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}>
|
||||||
|
|||||||
@ -61,6 +61,12 @@ 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
|
||||||
}
|
}
|
||||||
@ -98,6 +104,14 @@ 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
|
||||||
}>
|
}>
|
||||||
|
|
||||||
|
|||||||
@ -31,7 +31,7 @@ export default defineConfig({
|
|||||||
port: 3000,
|
port: 3000,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'https://ankao.yuchat.top',
|
target: 'http://127.0.0.1:8080',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user