Compare commits

..

18 Commits

Author SHA1 Message Date
f9a5e06df2 修复每日一练继续答题功能
问题:点击首页的每日一练快捷入口时,无法检测到未完成的考试记录

原因:GetExamRecordList 接口在指定 exam_id 参数时,仅返回 status='graded' 的记录,
过滤掉了 status='in_progress' 的未完成记录

修改:
- 移除 status='graded' 过滤条件
- 添加 user_id 过滤,确保用户只能查看自己的记录
- 修改排序为按创建时间倒序(最新的在前)
- 支持继续未完成的考试答题

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 01:08:18 +08:00
d04de0190c 支持继续未完成的每日一练答题
主要改动:
1. 点击每日一练前先查询是否有未完成的考试记录
2. 如果存在 in_progress 状态的记录,直接跳转继续答题
3. 如果没有未完成的记录,创建新的考试记录
4. 提升用户体验,避免重复开始考试导致进度丢失
2025-12-02 01:01:35 +08:00
2d778364e2 添加首页更新公告并实现关闭后不再显示功能
主要改动:
1. 在首页添加系统更新公告,展示三大更新内容:
   - 新增每日一练功能:系统每天自动生成练习试卷
   - 新增每日一练排行榜:实时查看今日答题排名
   - 调整模拟考试试卷组成:优化题型分布和分数配比
2. 实现公告关闭后持久化记忆功能:
   - 使用 localStorage 存储用户关闭状态
   - 点击关闭后下次访问不再显示
3. 公告样式采用 Ant Design Alert 组件
   - 信息类型提示,带图标
   - 可关闭,用户体验友好
2025-12-02 00:59:03 +08:00
0074e5978f 添加首页每日一练快捷入口并修复API调用问题
主要改动:
1. 修复每日一练排行榜API调用bug(request.get替代request)
2. 在首页快速开始区域添加每日一练快捷入口卡片
3. 实现点击卡片直接跳转到今日每日一练答题页面
4. 添加每日一练状态检测和错误提示

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 00:52:40 +08:00
2be5f49528 添加服务启动时自动检测并生成当天每日一练功能
- 新增 checkAndGenerateDailyExam 函数在应用启动时执行
- 使用 goroutine 异步执行检测,不阻塞服务启动
- 复用 DailyExamService 的幂等性逻辑,已存在则跳过
- 确保服务重启后当天的每日一练试卷已创建
2025-12-02 00:42:22 +08:00
4f7dfae855 整合三个排行榜为单一可切换的排行榜界面
- 合并每日一练排行榜、今日排行榜、总排行榜到一个界面
- 添加三个可点击切换的标签页
- 优化滑块动画,支持三个位置(左、中、右)
- 统一加载状态显示
- 保持各排行榜原有的数据展示和样式
2025-12-02 00:40:09 +08:00
fa2964e144 调整首页排行榜顺序:每日一练排行榜置于今日排行榜之前
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 00:35:36 +08:00
b1551e6deb 添加每日一练排行榜功能
- 修复 daily_exam_service.go 中的类型转换错误
- 在首页添加每日一练排行榜组件
- 显示今日每日一练的考试成绩和用时排行
- 当今日尚未生成每日一练时显示友好提示

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 00:31:33 +08:00
960f557ca4 添加每日一练功能(未完成排行榜前端)
后端功能:
- 添加Exam模型is_system字段标识系统试卷
- 创建每日一练服务,使用PostgreSQL分布式锁
- 集成cron定时任务,每天凌晨1点自动生成试卷
- 自动分享给所有用户(批量插入)
- API权限控制:系统试卷禁止删除和再次分享
- 添加GetDailyExamRanking API返回排行榜

前端功能:
- 添加is_system类型定义
- 系统试卷显示"系统"标签
- 系统试卷隐藏删除和分享按钮
- 添加getDailyExamRanking API方法

技术亮点:
- 使用PostgreSQL Advisory Lock实现分布式锁
- 使用robfig/cron/v3调度定时任务
- 批量插入提升分享性能

待完成:首页添加每日一练排行榜组件

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 00:26:51 +08:00
a77242c844 限制试卷分享和删除权限
- 只有自己创建的试卷才能分享给其他用户
- 只有自己创建的试卷才能删除
- 别人分享的试卷只显示考试、记录、答案、打印功能
- 使用 is_shared 字段判断试卷所有权

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 00:05:31 +08:00
e3e0671204 优化考试管理界面布局和交互
- 在考试失败页面添加"再考一次"按钮,支持快速重新开始考试
- 移除试卷卡片中的考试时长、及格分数和题目数量显示
- 优化试卷卡片布局:减少内边距和卡片间距,使界面更紧凑
- 修复统计标签样式:覆盖antd Tag默认间距,防止图标在文字溢出时缩小
- 实现响应式布局:移动端1列、平板2列、桌面端3列

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 00:03:12 +08:00
023ab1cc55 修正试卷打印页面的分值配置和考试说明
1. 调整题型分值映射:
   - 判断题:2.0分 → 1.0分(10题共10分)
   - 多选题:2.5分 → 2.0分(10题共20分)
   - 论述题:4.5分 → 10.0分(2选1共10分)

2. 简化考试说明:
   - 去除详细的题型分数列表
   - 保留关键答题提示:简答题作答要求、论述题选答规则

3. 优化论述题说明文案:
   - 改为"根据自己的职务,在以下2道论述题选择1道作答,共10分"
   - 明确说明需要根据职务类型(普通涉密人员/保密管理人员)选答

试卷总分仍为100分:
- 填空题:20题×2分=40分
- 判断题:10题×1分=10分
- 单选题:10题×1分=10分
- 多选题:10题×2分=20分
- 简答题:1题×10分=10分
- 论述题:2选1×10分=10分

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 23:17:30 +08:00
8d10ebc327 优化试卷打印页面布局和分页控制
## 主要改进

### 1. 题型标题和分值说明显示优化
- 将 Ant Design Text 组件改为原生 span 元素,确保在打印时不换行
- 将分值说明嵌套到题型标题内部,强制同行显示
- 适用于所有题型:填空题、判断题、单选题、多选题、简答题、论述题

### 2. 打印分页控制优化
- 添加 page-break-after: avoid 和 page-break-inside: avoid 到关键元素
- 试卷标题和考试说明保持在同一页
- 考试说明和填空题不分页
- 题型标题和第一道题保持在同一页
- 每道题目的所有部分(题干、选项、答题区域)保持完整,不被分页打断
- 题型之间尽量紧密排列,减少空白

### 3. 样式细节调整
- 题型标题使用 flex 布局,确保标题和说明在同一行
- 统一使用 marginLeft: 8px 作为标题和说明之间的间距
- 保持 A4 纸张 1cm 页边距设置

## 修改文件
- web/src/pages/ExamPrint.module.less - 打印样式优化
- web/src/pages/ExamPrint.tsx - 题型标题结构调整

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 23:04:39 +08:00
62281b5047 重构试卷分享系统:修复类型不匹配问题
本次提交重构了试卷分享功能,从"副本模式"改为"关联表模式",并修复了关键的类型不匹配问题。

## 主要更新

### 后端架构重构
- 新增 ExamShare 关联表模型,替代原有的试卷副本方式
- 修复 User.ID (int64) 与 ExamShare 外键的类型不匹配问题
- 更新所有相关 API 以使用新的关联表架构
- 添加 IsAccessibleBy 和 GetAccessibleExams 权限检查方法

### 类型系统修复
- ExamShare.SharedByID/SharedToID: uint → int64
- IsAccessibleBy/GetAccessibleExams 参数: uint → int64
- 修复所有涉及用户ID类型转换的代码

### 新增工具
- cmd/migrate_exam_shares.go: 数据迁移脚本(旧数据迁移)
- cmd/cleanup/main.go: 数据库表清理工具

### API 更新
- ShareExam: 创建分享关联记录而非复制试卷
- GetExamList: 返回分享人信息和参与人数统计
- GetExamRecord: 支持查看共享试卷的其他用户记录
- GetExamRecordList: 按试卷ID查询所有用户的考试记录

### 前端更新
- 更新 TypeScript 类型定义以匹配新的 API 响应
- 添加分享人标签显示("来自 XXX")
- 考试记录列表显示所有参与者信息

## 技术细节
- 使用 GORM 外键关联和 Preload 优化查询
- 添加唯一索引防止重复分享
- 事务保护数据一致性
- 软删除支持数据恢复

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 22:32:54 +08:00
2cc0c154dc 优化考试记录分数显示
修改内容:
- 移除考试记录中的总分显示
- 只显示实际得分,格式为"XX 分"
- 保持颜色标识(通过为绿色,未通过为红色)

原因:
- 简化显示,避免总分显示不准确的问题
- 用户更关注实际得分而非总分对比

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 21:37:29 +08:00
ccc77beef8 实现共享试卷考试记录查看功能
功能说明:
- 查看共享试卷的考试记录时,显示所有参与用户的成绩
- 按分数从高到低排序,方便查看排行
- 显示每个用户的头像、昵称和考试详情

实现细节:
1. 后端:修改 GetExamRecordList 接口
   - 指定试卷ID时自动识别共享关系
   - 查询原始试卷+所有分享副本的考试记录
   - 返回用户信息(username, nickname, avatar)
   - 按分数降序排序

2. 前端:更新考试记录展示
   - 显示用户头像和昵称
   - 保持原有的考试详情信息
   - 适配共享和非共享两种场景

修改文件:
- internal/handlers/exam_handler.go: 重构GetExamRecordList逻辑
- web/src/types/exam.ts: ExamRecord添加user字段
- web/src/pages/ExamManagement.tsx: 显示用户信息

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 21:34:20 +08:00
03f3e14f6e 添加共享试卷参与人数统计功能
功能说明:
- 后端:GetExamList 接口新增 participant_count 字段
- 前端:试卷卡片显示"X 人参与"标签
- 统计逻辑:计算原始试卷和所有分享副本的不同用户数

实现细节:
- 自动识别原始试卷和分享副本
- 统计所有已完成考试的不同用户
- 使用团队图标(TeamOutlined)展示参与人数

修改文件:
- internal/handlers/exam_handler.go: 添加参与人数统计逻辑
- web/src/types/exam.ts: 更新类型定义
- web/src/pages/ExamManagement.tsx: 显示参与人数标签

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 21:27:03 +08:00
3704d52a26 调整模拟考试试卷结构和评分规则
修改试卷组成为:
- 填空题:20道(40分,每题2分)
- 判断题:10道(10分,每题1分)
- 单选题:10道(10分,每题1分)
- 多选题:10道(20分,每题2分)
- 简答题:1道(10分)
- 论述题:2道供选择,根据职位类型作答1道(10分)

总分:100分(包含两道论述题但用户只需选答其中一道)

主要变更:
- exam_handler.go: 更新试卷创建时的题型配置
- exam_grading.go: 更新阅卷时的分值映射表
- 确保创建和评分逻辑一致

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 21:18:18 +08:00
20 changed files with 1223 additions and 205 deletions

38
cmd/cleanup/main.go Normal file
View 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
View 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
View File

@ -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
View File

@ -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=

View File

@ -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{}, // 用户答案表
) )

View File

@ -68,13 +68,13 @@ func gradeExam(recordID uint, examID uint, userID uint) {
// 使用固定的题型分值映射 // 使用固定的题型分值映射
scoreMap := map[string]float64{ scoreMap := map[string]float64{
"fill-in-blank": 2.0, "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
} }
// 评分 // 评分

View File

@ -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,44 +152,67 @@ 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 {
continue
}
stats := ExamWithStats{ stats := ExamWithStats{
Exam: exam, Exam: exam,
QuestionCount: len(questionIDs), QuestionCount: len(questionIDs),
} }
// 查询该试卷的考试记录统计 // 检查是否为分享试卷
if exam.UserID != uint(userID.(int64)) {
stats.IsShared = true
// 查询分享人信息
var share models.ExamShare
if err := db.Where("exam_id = ? AND shared_to_id = ?", exam.ID, userID.(int64)).
Preload("SharedBy").First(&share).Error; err == nil && share.SharedBy != nil {
stats.SharedBy = &struct {
ID int64 `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
}{
ID: share.SharedBy.ID,
Username: share.SharedBy.Username,
Nickname: share.SharedBy.Nickname,
}
}
}
// 当前用户的统计
var count int64 var count int64
db.Model(&models.ExamRecord{}).Where("exam_id = ? AND user_id = ?", exam.ID, userID).Count(&count) db.Model(&models.ExamRecord{}).Where("exam_id = ? AND user_id = ?", exam.ID, userID).Count(&count)
stats.AttemptCount = int(count) stats.AttemptCount = int(count)
// 查询最高分
var record models.ExamRecord var record models.ExamRecord
if err := db.Where("exam_id = ? AND user_id = ?", exam.ID, userID). if err := db.Where("exam_id = ? AND user_id = ?", exam.ID, userID).
Order("score DESC"). Order("score DESC").
@ -197,7 +220,6 @@ func GetExamList(c *gin.Context) {
stats.BestScore = record.Score stats.BestScore = record.Score
} }
// 查询是否有进行中的考试status为in_progress
var inProgressRecord models.ExamRecord var inProgressRecord models.ExamRecord
if err := db.Where("exam_id = ? AND user_id = ? AND status = ?", exam.ID, userID, "in_progress"). if err := db.Where("exam_id = ? AND user_id = ? AND status = ?", exam.ID, userID, "in_progress").
Order("created_at DESC"). Order("created_at DESC").
@ -206,9 +228,16 @@ func GetExamList(c *gin.Context) {
stats.InProgressRecordID = inProgressRecord.ID stats.InProgressRecordID = inProgressRecord.ID
} }
// 参与人数统计(简化:直接统计该试卷的不同用户)
var participantCount int64
db.Model(&models.ExamRecord{}).
Where("exam_id = ? AND status = ?", exam.ID, "graded").
Distinct("user_id").
Count(&participantCount)
stats.ParticipantCount = int(participantCount)
result = append(result, stats) result = append(result, stats)
} }
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
@ -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 var records []models.ExamRecord
if err := query.Preload("Exam"). 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").
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),
},
})
}

View File

@ -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 考试记录

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

View 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
View File

@ -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("每日一练检测完成")
}
}

View File

@ -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')
}

View File

@ -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;

View File

@ -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,6 +335,8 @@ const ExamManagement: React.FC = () => {
> >
{exam.has_in_progress_exam ? '继续' : '考试'} {exam.has_in_progress_exam ? '继续' : '考试'}
</Button>, </Button>,
// 只有非系统且自己创建的试卷才能分享
!exam.is_system && !exam.is_shared && (
<Button <Button
type="text" type="text"
icon={<ShareAltOutlined />} icon={<ShareAltOutlined />}
@ -329,7 +344,8 @@ const ExamManagement: React.FC = () => {
className={styles.actionButton} className={styles.actionButton}
> >
</Button>, </Button>
),
<Button <Button
type="text" type="text"
icon={<HistoryOutlined />} icon={<HistoryOutlined />}
@ -354,6 +370,8 @@ const ExamManagement: React.FC = () => {
> >
</Button>, </Button>,
// 只有非系统且自己创建的试卷才能删除
!exam.is_system && !exam.is_shared && (
<Button <Button
type="text" type="text"
danger danger
@ -363,25 +381,10 @@ const ExamManagement: React.FC = () => {
> >
</Button> </Button>
]} )
].filter(Boolean)}
> >
<div className={styles.cardContent}> <div className={styles.cardContent}>
<div className={styles.examInfo}>
<div className={styles.infoItem}>
<ClockCircleOutlined className={styles.infoIcon} />
<span className={styles.infoText}>{exam.duration} </span>
</div>
<div className={styles.infoItem}>
<CheckCircleOutlined className={styles.infoIcon} />
<span className={styles.infoText}> {exam.pass_score} </span>
</div>
<div className={styles.infoItem}>
<FileTextOutlined className={styles.infoIcon} />
<span className={styles.infoText}>{exam.question_count || 0} </span>
</div>
</div>
<Divider className={styles.divider} />
<div className={styles.examStats}> <div className={styles.examStats}>
<div className={styles.statItem}> <div className={styles.statItem}>
@ -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>
)} )}

View File

@ -205,7 +205,7 @@
display: none !important; display: none !important;
} }
// A4纸张设置 - 增加边距以确保圆角不被遮挡 // A4纸张设置
@page { @page {
size: A4; size: A4;
margin: 1cm; margin: 1cm;
@ -225,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;

View File

@ -42,12 +42,12 @@ const TYPE_NAME: Record<string, string> = {
// 题型分值映射 // 题型分值映射
const TYPE_SCORE: Record<string, number> = { const TYPE_SCORE: Record<string, number> = {
'fill-in-blank': 2.0, 'fill-in-blank': 2.0,
'true-false': 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 && (
<Text type="secondary" className={styles.groupScore}> <span className={styles.groupScore} style={{ marginLeft: '8px' }}>
{questions.length}{TYPE_SCORE[type]}{totalScore} {questions.length}{TYPE_SCORE[type]}{totalScore}
</Text> </span>
)} )}
</span>
</div> </div>
<div className={styles.questionsList}> <div className={styles.questionsList}>
{questions.map((question, index) => { {questions.map((question, index) => {
@ -448,8 +448,8 @@ const ExamPrint: React.FC = () => {
<Title level={4}></Title> <Title level={4}></Title>
<ul> <ul>
<li>10060</li> <li>10060</li>
<li>8</li> <li></li>
<li>921</li> <li>21</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}> 2110
219 </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))}

View File

@ -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>
); );
}; };

View File

@ -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}>

View File

@ -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
}> }>

View File

@ -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,
}, },
}, },