Compare commits
No commits in common. "master-backup" and "master" have entirely different histories.
master-bac
...
master
@ -1,49 +0,0 @@
|
||||
# Git 相关
|
||||
.git
|
||||
.gitignore
|
||||
.github
|
||||
|
||||
# 文档
|
||||
*.md
|
||||
LICENSE
|
||||
|
||||
# IDE 配置
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Go 相关
|
||||
bin/
|
||||
*.exe
|
||||
*.test
|
||||
*.out
|
||||
|
||||
# 前端相关
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
web/.vite/
|
||||
web/yarn-error.log
|
||||
web/package-lock.json
|
||||
|
||||
# 环境变量和配置
|
||||
.env
|
||||
.env.local
|
||||
*.local
|
||||
|
||||
# 日志
|
||||
*.log
|
||||
|
||||
# 临时文件
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
|
||||
# OS 相关
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Docker 相关
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
docker-compose.yml
|
||||
449
CLAUDE.md
449
CLAUDE.md
@ -62,10 +62,10 @@ func MiddlewareName() gin.HandlerFunc {
|
||||
- **internal/models/** - 数据模型
|
||||
- [user.go](internal/models/user.go) - 用户模型
|
||||
- [practice_question.go](internal/models/practice_question.go) - 练习题模型
|
||||
- **internal/services/** - 业务服务
|
||||
- [ai_grading.go](internal/services/ai_grading.go) - AI评分服务
|
||||
- **internal/database/** - 数据库连接和初始化
|
||||
- **pkg/config/** - 配置管理(数据库配置、AI配置等)
|
||||
- **pkg/config/** - 配置管理(数据库配置等)
|
||||
- **scripts/** - 工具脚本
|
||||
- [import_questions.go](scripts/import_questions.go) - 题目数据导入脚本
|
||||
|
||||
## 常用命令
|
||||
|
||||
@ -82,6 +82,9 @@ go fmt ./...
|
||||
|
||||
# 检查代码常见问题
|
||||
go vet ./...
|
||||
|
||||
# 导入练习题数据(首次运行需要)
|
||||
go run scripts/import_questions.go
|
||||
```
|
||||
|
||||
### 构建
|
||||
@ -116,7 +119,6 @@ go test -v ./...
|
||||
- **框架**: 使用 Gin v1.11.0
|
||||
- **ORM**: 使用 GORM v1.31.1
|
||||
- **数据库**: PostgreSQL (配置在 [pkg/config/config.go](pkg/config/config.go))
|
||||
- **AI服务**: 使用 go-openai SDK v1.41.2,配置在 [pkg/config/config.go](pkg/config/config.go)
|
||||
- **服务器端口**: :8080 (在 [main.go:42](main.go#L42) 中配置)
|
||||
- **处理器签名**: 所有处理器使用 `func(c *gin.Context)` 模式
|
||||
- **JSON 响应**: 使用 `c.JSON()` 方法配合 `gin.H{}` 或结构体
|
||||
@ -124,7 +126,6 @@ go test -v ./...
|
||||
- **路由注册**: 路由在 [main.go](main.go) 中使用 `r.GET()`、`r.POST()` 等注册
|
||||
- **中间件**: 使用 `r.Use()` 全局应用或通过路由分组应用到特定路由
|
||||
- **密码加密**: 使用 bcrypt 加密存储用户密码
|
||||
- **AI评分**: 简答题使用AI智能评分,提供分数、评语和改进建议
|
||||
|
||||
## 添加新功能
|
||||
|
||||
@ -151,394 +152,33 @@ go test -v ./...
|
||||
3. 在 [internal/database/database.go](internal/database/database.go) 的 `InitDB()` 中添加 `AutoMigrate`
|
||||
4. 在处理器中使用 `database.GetDB()` 进行数据库操作
|
||||
|
||||
### 导入数据到数据库
|
||||
**示例**: 练习题数据导入
|
||||
|
||||
## AI评分系统
|
||||
1. **准备JSON数据文件**: 如 [practice_question_pool.json](practice_question_pool.json)
|
||||
2. **创建数据模型**: 在 `internal/models/` 中定义数据结构
|
||||
3. **创建导入脚本**: 在 `scripts/` 目录创建导入脚本,如 [import_questions.go](scripts/import_questions.go)
|
||||
4. **解析JSON并插入**:
|
||||
```go
|
||||
// 读取JSON文件
|
||||
data, _ := os.ReadFile("data.json")
|
||||
|
||||
项目集成了AI智能评分功能,专门用于对简答题进行评分和反馈。
|
||||
// 解析JSON
|
||||
var items []YourStruct
|
||||
json.Unmarshal(data, &items)
|
||||
|
||||
### AI评分配置
|
||||
|
||||
AI服务配置位于 [pkg/config/config.go](pkg/config/config.go):
|
||||
|
||||
```go
|
||||
type AIConfig struct {
|
||||
BaseURL string // AI API地址
|
||||
APIKey string // API密钥
|
||||
Model string // 使用的模型名称
|
||||
}
|
||||
```
|
||||
|
||||
**配置方式**:
|
||||
1. **默认配置**: 直接在代码中设置默认值
|
||||
2. **环境变量**: 通过环境变量覆盖默认配置
|
||||
```bash
|
||||
export AI_BASE_URL="https://ai.yuchat.top"
|
||||
export AI_API_KEY="你的API密钥"
|
||||
export AI_MODEL="deepseek-v3"
|
||||
// 插入数据库
|
||||
db := database.GetDB()
|
||||
for _, item := range items {
|
||||
db.Create(&item)
|
||||
}
|
||||
```
|
||||
5. **运行导入脚本**: `go run scripts/import_questions.go`
|
||||
|
||||
### AI评分服务
|
||||
|
||||
AI评分服务实现位于 [internal/services/ai_grading.go](internal/services/ai_grading.go):
|
||||
|
||||
**主要功能**:
|
||||
- `NewAIGradingService()` - 创建AI评分服务实例
|
||||
- `GradeShortAnswer(question, standardAnswer, userAnswer)` - 对简答题进行AI评分
|
||||
|
||||
**返回结果**:
|
||||
```go
|
||||
type AIGradingResult struct {
|
||||
Score float64 // 得分 (0-100)
|
||||
IsCorrect bool // 是否正确 (Score >= 60 视为正确)
|
||||
Feedback string // 评语
|
||||
Suggestion string // 改进建议
|
||||
}
|
||||
```
|
||||
|
||||
### 集成方式
|
||||
|
||||
在 [practice_handler.go](internal/handlers/practice_handler.go) 的 `SubmitPracticeAnswer` 函数中:
|
||||
|
||||
```go
|
||||
// 对简答题使用AI评分
|
||||
if question.Type == "short-answer" {
|
||||
aiService := services.NewAIGradingService()
|
||||
aiResult, err := aiService.GradeShortAnswer(
|
||||
question.Question,
|
||||
standardAnswerStr,
|
||||
userAnswerStr,
|
||||
)
|
||||
// 使用AI评分结果
|
||||
correct = aiResult.IsCorrect
|
||||
aiGrading = &models.AIGrading{
|
||||
Score: aiResult.Score,
|
||||
Feedback: aiResult.Feedback,
|
||||
Suggestion: aiResult.Suggestion,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### API响应格式
|
||||
|
||||
简答题提交后,响应中会包含 `ai_grading` 字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"correct": true,
|
||||
"user_answer": "用户的答案",
|
||||
"correct_answer": "标准答案",
|
||||
"ai_grading": {
|
||||
"score": 85,
|
||||
"feedback": "答案基本正确,要点全面",
|
||||
"suggestion": "可以补充一些具体的例子"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 注意事项
|
||||
|
||||
- AI评分仅对 `short-answer` 类型的题目生效
|
||||
- 其他题型(填空题、判断题、选择题)仍使用传统的精确匹配方式
|
||||
- AI评分失败时不影响主流程,会记录日志并使用传统评分方式
|
||||
- 评分采用温度参数 0.3,确保评分结果稳定可靠
|
||||
|
||||
### 自定义AI评分提示词
|
||||
|
||||
如需调整评分标准,修改 [ai_grading.go](internal/services/ai_grading.go) 中的 `prompt` 变量:
|
||||
|
||||
```go
|
||||
prompt := fmt.Sprintf(`你是一位专业的阅卷老师,请对以下简答题进行评分。
|
||||
|
||||
题目:%s
|
||||
标准答案:%s
|
||||
学生答案:%s
|
||||
|
||||
请按照以下要求进行评分:
|
||||
1. 给出一个0-100的分数
|
||||
2. 判断答案是否正确(60分及以上为正确)
|
||||
3. 给出简短的评语(不超过50字)
|
||||
4. 如果答案不完善,给出改进建议(不超过50字)
|
||||
...
|
||||
`, question, standardAnswer, userAnswer)
|
||||
```
|
||||
|
||||
## 错题本系统
|
||||
|
||||
**重要更新**: 错题本系统已全面重构为 新版本,提供更强大的功能和更好的用户体验。
|
||||
|
||||
### 核心特性
|
||||
|
||||
1. **多次错误记录历史** - 保存每次答错的完整记录,而非仅保留最后一次
|
||||
2. **智能复习系统** - 基于艾宾浩斯遗忘曲线的间隔重复算法
|
||||
3. **标签管理系统** - 支持自定义标签,灵活分类错题
|
||||
4. **智能推荐引擎** - 优先推荐需要复习的高频错题
|
||||
5. **掌握度追踪** - 自动计算和更新每道题的掌握度(0-100%)
|
||||
6. **详细统计分析** - 错题趋势、掌握度分布、薄弱知识点分析
|
||||
|
||||
### 数据模型设计
|
||||
|
||||
#### 错题记录表 (`wrong_questions`)
|
||||
|
||||
```go
|
||||
type WrongQuestion struct {
|
||||
ID uint // 主键
|
||||
UserID uint // 用户ID
|
||||
QuestionID uint // 题目ID
|
||||
FirstWrongTime time.Time // 首次错误时间
|
||||
LastWrongTime time.Time // 最后错误时间
|
||||
TotalWrongCount int // 总错误次数
|
||||
MasteryLevel int // 掌握度 (0-100)
|
||||
NextReviewTime *time.Time // 下次复习时间
|
||||
ConsecutiveCorrect int // 连续答对次数
|
||||
IsMastered bool // 是否已掌握
|
||||
}
|
||||
```
|
||||
|
||||
#### 错误历史表 (`wrong_question_history`)
|
||||
|
||||
```go
|
||||
type WrongQuestionHistory struct {
|
||||
ID uint // 主键
|
||||
WrongQuestionID uint // 关联错题记录
|
||||
UserAnswer string // 用户答案 (JSON)
|
||||
CorrectAnswer string // 正确答案 (JSON)
|
||||
AnsweredAt time.Time // 答题时间
|
||||
TimeSpent int // 答题用时(秒)
|
||||
IsCorrect bool // 本次是否正确
|
||||
}
|
||||
```
|
||||
|
||||
### 间隔重复算法(艾宾浩斯遗忘曲线)
|
||||
|
||||
系统采用科学的间隔重复算法,根据用户答题情况自动安排复习时间:
|
||||
|
||||
**复习间隔策略**: `[1天, 3天, 7天, 15天, 30天, 60天]`
|
||||
|
||||
- 答错时:重置连续答对次数,重新从第一个间隔开始
|
||||
- 答对时:连续答对次数+1,进入下一个复习间隔
|
||||
- 完全掌握:连续答对6次后标记为"已掌握"
|
||||
|
||||
**实现位置**: [internal/models/wrong_question_v2.go](internal/models/wrong_question_v2.go#L156)
|
||||
|
||||
```go
|
||||
// 默认复习策略
|
||||
var DefaultReviewStrategy = ReviewStrategy{
|
||||
Intervals: []int{1, 3, 7, 15, 30, 60},
|
||||
}
|
||||
|
||||
// 自动计算下次复习时间
|
||||
func (wq *WrongQuestion) CalculateNextReviewTime() {
|
||||
strategy := DefaultReviewStrategy
|
||||
level := wq.ConsecutiveCorrect
|
||||
|
||||
if level >= len(strategy.Intervals) {
|
||||
wq.IsMastered = true // 已完全掌握
|
||||
wq.MasteryLevel = 100
|
||||
wq.NextReviewTime = nil
|
||||
return
|
||||
}
|
||||
|
||||
days := strategy.Intervals[level]
|
||||
nextTime := time.Now().Add(time.Duration(days) * 24 * time.Hour)
|
||||
wq.NextReviewTime = &nextTime
|
||||
wq.MasteryLevel = (level * 100) / len(strategy.Intervals)
|
||||
}
|
||||
```
|
||||
|
||||
### API 接口
|
||||
|
||||
所有 API 都在 `/api/v2` 路径下,与旧版 API 共存以保持向后兼容。
|
||||
|
||||
#### 错题管理 API
|
||||
|
||||
| 方法 | 路由 | 功能 | 查询参数 |
|
||||
|------|------|------|----------|
|
||||
| GET | `/api/wrong-questions` | 获取错题列表 | `is_mastered`, `tag`, `type`, `sort` |
|
||||
| GET | `/api/wrong-questions/:id` | 获取错题详情 | - |
|
||||
| GET | `/api/wrong-questions/stats` | 获取错题统计 | - |
|
||||
| GET | `/api/wrong-questions/recommended` | 获取推荐错题 | `limit` |
|
||||
| DELETE | `/api/wrong-questions/:id` | 删除错题 | - |
|
||||
| DELETE | `/api/wrong-questions` | 清空错题本 | - |
|
||||
|
||||
**排序选项** (`sort` 参数):
|
||||
- `review_time` - 按复习时间排序(最需要复习的在前)
|
||||
- `wrong_count` - 按错误次数排序(错误最多的在前)
|
||||
- `mastery_level` - 按掌握度排序(掌握度最低的在前)
|
||||
- `time` - 按最后错误时间排序(默认)
|
||||
|
||||
|
||||
### 智能推荐算法
|
||||
|
||||
推荐系统采用三级策略,优先推荐最需要复习的题目:
|
||||
|
||||
1. **优先级 1**: 到期需要复习的题目(`next_review_time <= NOW()`)
|
||||
2. **优先级 2**: 高频错题且掌握度低(`wrong_count DESC, mastery_level ASC`)
|
||||
3. **优先级 3**: 最近答错的题目(`last_wrong_time DESC`)
|
||||
|
||||
**实现位置**: [internal/services/wrong_question_service.go](internal/services/wrong_question_service.go#L228)
|
||||
|
||||
### 统计数据
|
||||
|
||||
错题统计 提供更丰富的数据:
|
||||
|
||||
```json
|
||||
{
|
||||
"total_wrong": 50,
|
||||
"mastered": 10,
|
||||
"not_mastered": 40,
|
||||
"need_review": 15,
|
||||
"type_stats": { "single-choice": 20, "multiple-choice": 15, "fill-in-blank": 15 },
|
||||
"category_stats": { "数学": 25, "语文": 15, "英语": 10 },
|
||||
"mastery_level_dist": { "很差": 10, "较差": 15, "一般": 10, "良好": 10, "优秀": 5 },
|
||||
"tag_stats": { "重点": 20, "难点": 15 },
|
||||
"trend_data": [
|
||||
{ "date": "01-01", "count": 5 },
|
||||
{ "date": "01-02", "count": 3 },
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### 前端集成
|
||||
|
||||
前端 TypeScript 类型定义位于 [web/src/types/question.ts](web/src/types/question.ts):
|
||||
|
||||
```typescript
|
||||
// 错题记录
|
||||
interface WrongQuestion {
|
||||
id: number
|
||||
question_id: number
|
||||
question?: Question
|
||||
first_wrong_time: string
|
||||
last_wrong_time: string
|
||||
total_wrong_count: number
|
||||
mastery_level: number // 0-100
|
||||
next_review_time?: string
|
||||
consecutive_correct: number
|
||||
is_mastered: boolean
|
||||
recent_history?: WrongQuestionHistory[]
|
||||
}
|
||||
```
|
||||
|
||||
API 调用方法位于 [web/src/api/question.ts](web/src/api/question.ts):
|
||||
|
||||
```typescript
|
||||
// 获取错题列表(支持筛选和排序)
|
||||
getWrongQuestionsV2(filter?: WrongQuestionFilter)
|
||||
|
||||
// 获取推荐错题
|
||||
getRecommendedWrongQuestions(limit: number = 10)
|
||||
|
||||
// 获取错题统计
|
||||
getWrongQuestionStats()
|
||||
|
||||
```
|
||||
|
||||
### 注意事项
|
||||
|
||||
- API 与旧版 API 完全兼容,可以同时使用
|
||||
- 答题时会自动使用 API 记录错题
|
||||
- 答对错题本中的题目时,会自动更新连续答对次数
|
||||
- 掌握度达到 100% 时,题目会被标记为"已掌握"
|
||||
- 标签功能支持自定义,建议按知识点或难度分类
|
||||
|
||||
## AI评分系统
|
||||
|
||||
项目集成了AI智能评分功能,专门用于对简答题进行评分和反馈。
|
||||
|
||||
### AI评分配置
|
||||
|
||||
AI服务配置位于 [pkg/config/config.go](pkg/config/config.go):
|
||||
|
||||
```go
|
||||
type AIConfig struct {
|
||||
BaseURL string // AI API地址
|
||||
APIKey string // API密钥
|
||||
Model string // 使用的模型名称
|
||||
}
|
||||
```
|
||||
|
||||
**配置方式**:
|
||||
1. **默认配置**: 直接在代码中设置默认值
|
||||
2. **环境变量**: 通过环境变量覆盖默认配置
|
||||
```bash
|
||||
export AI_BASE_URL="https://ai.yuchat.top"
|
||||
export AI_API_KEY="你的API密钥"
|
||||
export AI_MODEL="deepseek-v3"
|
||||
```
|
||||
|
||||
### AI评分服务
|
||||
|
||||
AI评分服务实现位于 [internal/services/ai_grading.go](internal/services/ai_grading.go):
|
||||
|
||||
**主要功能**:
|
||||
- `NewAIGradingService()` - 创建AI评分服务实例
|
||||
- `GradeShortAnswer(question, standardAnswer, userAnswer)` - 对简答题进行AI评分
|
||||
|
||||
**返回结果**:
|
||||
```go
|
||||
type AIGradingResult struct {
|
||||
Score float64 // 得分 (0-100)
|
||||
IsCorrect bool // 是否正确 (Score >= 60 视为正确)
|
||||
Feedback string // 评语
|
||||
Suggestion string // 改进建议
|
||||
}
|
||||
```
|
||||
|
||||
### 集成方式
|
||||
|
||||
在 [practice_handler.go](internal/handlers/practice_handler.go) 的 `SubmitPracticeAnswer` 函数中:
|
||||
|
||||
```go
|
||||
// 对简答题使用AI评分
|
||||
if question.Type == "short-answer" {
|
||||
aiService := services.NewAIGradingService()
|
||||
aiResult, err := aiService.GradeShortAnswer(
|
||||
question.Question,
|
||||
standardAnswerStr,
|
||||
userAnswerStr,
|
||||
)
|
||||
// 使用AI评分结果
|
||||
correct = aiResult.IsCorrect
|
||||
aiGrading = &models.AIGrading{
|
||||
Score: aiResult.Score,
|
||||
Feedback: aiResult.Feedback,
|
||||
Suggestion: aiResult.Suggestion,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### API响应格式
|
||||
|
||||
简答题提交后,响应中会包含 `ai_grading` 字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"correct": true,
|
||||
"user_answer": "用户的答案",
|
||||
"correct_answer": "标准答案",
|
||||
"ai_grading": {
|
||||
"score": 85,
|
||||
"feedback": "答案基本正确,要点全面",
|
||||
"suggestion": "可以补充一些具体的例子"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 注意事项
|
||||
|
||||
- AI评分仅对 `short-answer` 类型的题目生效
|
||||
- 其他题型(填空题、判断题、选择题)仍使用传统的精确匹配方式
|
||||
- AI评分失败时不影响主流程,会记录日志并使用传统评分方式
|
||||
- 评分采用温度参数 0.3,确保评分结果稳定可靠
|
||||
|
||||
**注意事项**:
|
||||
- JSON中复杂数据(如数组、对象)需要序列化为字符串存储
|
||||
- 使用唯一索引防止重复导入
|
||||
- 大批量导入建议使用事务提高性能
|
||||
|
||||
## 前端开发规范
|
||||
|
||||
@ -573,40 +213,25 @@ if question.Type == "short-answer" {
|
||||
### UI 组件使用原则
|
||||
**重要**: 在开发前端页面时,必须优先使用 UI 框架的组件。
|
||||
|
||||
- **优先使用 Ant Design 组件**: 项目使用 **antd (Ant Design)** 作为 UI 框架,开发时应优先查找并使用框架提供的组件
|
||||
- **优先使用 antd-mobile 组件**: 项目使用 antd-mobile 作为 UI 框架,开发时应优先查找并使用框架提供的组件
|
||||
- **常用组件示例**:
|
||||
- 表单输入: 使用 `Input` 组件,而非原生 `<input>`
|
||||
- 按钮: 使用 `Button` 组件,而非原生 `<button>`
|
||||
- 表单: 使用 `Form` 和 `Form.Item` 组件
|
||||
- 提示信息: 使用 `message` 组件,而非自定义提示框
|
||||
- 对话框: 使用 `Modal` 组件
|
||||
- 提示信息: 使用 `Toast` 组件,而非自定义提示框
|
||||
- 对话框: 使用 `Dialog` 组件
|
||||
- 列表: 使用 `List` 组件
|
||||
- 布局: 使用 `Row`、`Col`、`Layout` 等布局组件
|
||||
- 图标: 使用 `@ant-design/icons` 包中的图标
|
||||
- **仅在必要时自定义**: 只有当 antd 没有提供对应组件时,才使用自定义组件
|
||||
- **仅在必要时自定义**: 只有当 antd-mobile 没有提供对应组件时,才使用自定义组件
|
||||
- **样式处理**: 使用 CSS Modules (`.module.less`) 编写组件样式,避免全局样式污染
|
||||
- **主题定制**: 在 [web/vite.config.ts](web/vite.config.ts) 中通过 `modifyVars` 定制 antd 主题
|
||||
|
||||
### 前端项目结构
|
||||
- **web/src/pages/** - 页面组件
|
||||
- **web/src/components/** - 可复用组件
|
||||
- **web/src/pages/*.module.less** - 页面样式文件 (CSS Modules)
|
||||
- **web/vite.config.ts** - Vite 配置文件(包含代理配置和 antd 主题定制)
|
||||
- **web/vite.config.ts** - Vite 配置文件(包含代理配置)
|
||||
|
||||
### 响应式设计
|
||||
**重要**: 应用采用响应式设计,同时适配移动端和PC端。
|
||||
|
||||
- **响应式断点**:
|
||||
- 移动端: `max-width: 768px`
|
||||
- 平板: `769px ~ 1024px`
|
||||
- PC端: `min-width: 1025px`
|
||||
- **布局适配**: 使用 antd 的 Grid 系统 (`Row`、`Col`) 实现响应式布局
|
||||
- **移动端优化**:
|
||||
- 底部导航栏仅在移动端显示
|
||||
- 触摸区域大小适中(最小 44x44px)
|
||||
- 禁止双指缩放
|
||||
- **PC端优化**:
|
||||
- 内容居中,最大宽度限制
|
||||
- 隐藏移动端特有的底部导航栏
|
||||
- 更大的字体和间距
|
||||
### 移动端适配
|
||||
- **禁止缩放**: 应用已配置防止移动端双指缩放
|
||||
- **响应式设计**: 优先考虑移动端布局和交互
|
||||
- **触摸优化**: 使用合适的触摸区域大小(最小 44x44px)
|
||||
|
||||
|
||||
253
DOCKER.md
253
DOCKER.md
@ -1,253 +0,0 @@
|
||||
# Docker 部署指南
|
||||
|
||||
本文档说明如何使用 Docker 部署 AnKao 应用。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 使用 Docker Compose (推荐)
|
||||
|
||||
最简单的方式是使用 docker-compose,它会自动启动数据库和应用:
|
||||
|
||||
```bash
|
||||
# 构建并启动所有服务
|
||||
docker-compose up -d
|
||||
|
||||
# 查看日志
|
||||
docker-compose logs -f
|
||||
|
||||
# 停止服务
|
||||
docker-compose down
|
||||
|
||||
# 停止并删除数据
|
||||
docker-compose down -v
|
||||
```
|
||||
|
||||
访问应用: http://localhost:8080
|
||||
|
||||
### 手动使用 Docker
|
||||
|
||||
如果你想单独构建和运行容器:
|
||||
|
||||
#### 1. 构建镜像
|
||||
|
||||
```bash
|
||||
docker build -t ankao:latest .
|
||||
```
|
||||
|
||||
#### 2. 启动 PostgreSQL 数据库
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name ankao-postgres \
|
||||
-e POSTGRES_DB=ankao \
|
||||
-e POSTGRES_USER=postgres \
|
||||
-e POSTGRES_PASSWORD=postgres \
|
||||
-p 5432:5432 \
|
||||
postgres:16-alpine
|
||||
```
|
||||
|
||||
#### 3. 启动应用
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name ankao-app \
|
||||
-p 8080:8080 \
|
||||
-e DB_HOST=host.docker.internal \
|
||||
-e DB_PORT=5432 \
|
||||
-e DB_USER=postgres \
|
||||
-e DB_PASSWORD=postgres \
|
||||
-e DB_NAME=ankao \
|
||||
-e DB_SSLMODE=disable \
|
||||
ankao:latest
|
||||
```
|
||||
|
||||
**注意**: 在 Windows 和 Mac 上使用 `host.docker.internal` 连接宿主机的数据库。在 Linux 上需要使用 `--network host` 或创建自定义网络。
|
||||
|
||||
## 环境变量配置
|
||||
|
||||
应用支持以下环境变量配置:
|
||||
|
||||
### 数据库配置
|
||||
|
||||
| 环境变量 | 描述 | 默认值 |
|
||||
|---------|------|--------|
|
||||
| `DB_HOST` | 数据库主机地址 | pgsql.yuchat.top |
|
||||
| `DB_PORT` | 数据库端口 | 5432 |
|
||||
| `DB_USER` | 数据库用户名 | postgres |
|
||||
| `DB_PASSWORD` | 数据库密码 | longqi@1314 |
|
||||
| `DB_NAME` | 数据库名称 | ankao |
|
||||
| `DB_SSLMODE` | SSL 模式 | disable |
|
||||
|
||||
### 应用配置
|
||||
|
||||
| 环境变量 | 描述 | 默认值 |
|
||||
|---------|------|--------|
|
||||
| `GIN_MODE` | Gin 运行模式 (debug/release) | debug |
|
||||
|
||||
## Docker Compose 配置说明
|
||||
|
||||
`docker-compose.yml` 文件定义了两个服务:
|
||||
|
||||
1. **postgres**: PostgreSQL 16 数据库
|
||||
- 端口: 5432
|
||||
- 数据持久化: 使用 Docker volume `postgres_data`
|
||||
- 健康检查: 确保数据库就绪后再启动应用
|
||||
|
||||
2. **app**: AnKao 应用
|
||||
- 端口: 8080
|
||||
- 依赖 postgres 服务
|
||||
- 自动重启策略
|
||||
|
||||
## 生产环境部署建议
|
||||
|
||||
### 1. 修改默认密码
|
||||
|
||||
**重要**: 生产环境中务必修改数据库密码!
|
||||
|
||||
编辑 `docker-compose.yml`:
|
||||
```yaml
|
||||
environment:
|
||||
POSTGRES_PASSWORD: 你的强密码
|
||||
DB_PASSWORD: 你的强密码
|
||||
```
|
||||
|
||||
### 2. 使用外部数据库
|
||||
|
||||
如果使用外部 PostgreSQL 数据库,只需启动 app 服务:
|
||||
|
||||
```bash
|
||||
docker-compose up -d app
|
||||
```
|
||||
|
||||
并配置相应的环境变量。
|
||||
|
||||
### 3. 配置反向代理
|
||||
|
||||
生产环境建议使用 Nginx 作为反向代理:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 启用 HTTPS
|
||||
|
||||
使用 Let's Encrypt 免费 SSL 证书:
|
||||
|
||||
```bash
|
||||
# 安装 certbot
|
||||
apt-get install certbot python3-certbot-nginx
|
||||
|
||||
# 获取证书
|
||||
certbot --nginx -d your-domain.com
|
||||
```
|
||||
|
||||
### 5. 备份数据
|
||||
|
||||
定期备份 PostgreSQL 数据:
|
||||
|
||||
```bash
|
||||
# 备份数据库
|
||||
docker exec ankao-postgres pg_dump -U postgres ankao > backup.sql
|
||||
|
||||
# 恢复数据库
|
||||
cat backup.sql | docker exec -i ankao-postgres psql -U postgres ankao
|
||||
```
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 查看应用日志
|
||||
|
||||
```bash
|
||||
# 查看所有服务日志
|
||||
docker-compose logs
|
||||
|
||||
# 查看应用日志
|
||||
docker-compose logs app
|
||||
|
||||
# 查看数据库日志
|
||||
docker-compose logs postgres
|
||||
|
||||
# 实时查看日志
|
||||
docker-compose logs -f app
|
||||
```
|
||||
|
||||
### 进入容器调试
|
||||
|
||||
```bash
|
||||
# 进入应用容器
|
||||
docker exec -it ankao-app sh
|
||||
|
||||
# 进入数据库容器
|
||||
docker exec -it ankao-postgres sh
|
||||
```
|
||||
|
||||
### 常见问题
|
||||
|
||||
#### 1. 应用无法连接数据库
|
||||
|
||||
检查:
|
||||
- 数据库是否已启动: `docker-compose ps`
|
||||
- 网络配置是否正确
|
||||
- 环境变量配置是否正确
|
||||
|
||||
#### 2. 端口被占用
|
||||
|
||||
修改 `docker-compose.yml` 中的端口映射:
|
||||
```yaml
|
||||
ports:
|
||||
- "8081:8080" # 将宿主机端口改为 8081
|
||||
```
|
||||
|
||||
#### 3. 构建失败
|
||||
|
||||
清理缓存重新构建:
|
||||
```bash
|
||||
docker-compose build --no-cache
|
||||
```
|
||||
|
||||
## 镜像优化
|
||||
|
||||
当前 Dockerfile 使用多阶段构建,最终镜像基于 Alpine Linux,体积小且安全。
|
||||
|
||||
镜像大小约:
|
||||
- 前端构建阶段: ~200MB (不包含在最终镜像)
|
||||
- 后端构建阶段: ~300MB (不包含在最终镜像)
|
||||
- 最终运行镜像: ~30MB
|
||||
|
||||
## 更新应用
|
||||
|
||||
```bash
|
||||
# 拉取最新代码
|
||||
git pull
|
||||
|
||||
# 重新构建并启动
|
||||
docker-compose up -d --build
|
||||
|
||||
# 或者分步执行
|
||||
docker-compose build
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## 清理资源
|
||||
|
||||
```bash
|
||||
# 停止并删除容器
|
||||
docker-compose down
|
||||
|
||||
# 删除容器和数据卷
|
||||
docker-compose down -v
|
||||
|
||||
# 删除镜像
|
||||
docker rmi ankao:latest
|
||||
```
|
||||
71
Dockerfile
71
Dockerfile
@ -1,71 +0,0 @@
|
||||
# ============================================
|
||||
# 第一阶段: 构建前端应用
|
||||
# ============================================
|
||||
FROM node:22-alpine AS frontend-builder
|
||||
|
||||
WORKDIR /app/web
|
||||
|
||||
# 复制前端依赖文件
|
||||
COPY web/package.json web/yarn.lock ./
|
||||
|
||||
# 安装依赖
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
# 复制前端源代码
|
||||
COPY web/ ./
|
||||
|
||||
# 构建前端应用
|
||||
RUN yarn build
|
||||
|
||||
# ============================================
|
||||
# 第二阶段: 构建 Go 后端应用
|
||||
# ============================================
|
||||
FROM golang:1.25.1-alpine AS backend-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装必要的构建工具
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# 配置 Go 代理(使用国内镜像加速)
|
||||
ENV GOPROXY=https://goproxy.cn,https://goproxy.io,https://mirrors.aliyun.com/goproxy/,direct
|
||||
ENV GOSUMDB=off
|
||||
|
||||
# 复制 Go 依赖文件
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
# 下载依赖
|
||||
RUN go mod download
|
||||
|
||||
# 复制后端源代码
|
||||
COPY . .
|
||||
|
||||
# 构建 Go 应用
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server main.go
|
||||
|
||||
# ============================================
|
||||
# 第三阶段: 创建最终运行镜像
|
||||
# ============================================
|
||||
FROM alpine:latest
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装运行时依赖
|
||||
RUN apk --no-cache add ca-certificates tzdata
|
||||
|
||||
# 设置时区为上海
|
||||
ENV TZ=Asia/Shanghai
|
||||
|
||||
# 从后端构建阶段复制可执行文件
|
||||
COPY --from=backend-builder /app/server ./server
|
||||
|
||||
# 从前端构建阶段复制构建产物
|
||||
# 直接将整个 dist 目录复制为 web 目录
|
||||
# dist 目录包含:index.html, assets/, icon.svg 等所有构建产物
|
||||
COPY --from=frontend-builder /app/web/dist ./web
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 8080
|
||||
|
||||
# 启动应用
|
||||
CMD ["./server"]
|
||||
50
README.md
50
README.md
@ -64,8 +64,10 @@ yarn dev
|
||||
|
||||
#### 练习题相关
|
||||
- `GET /api/practice/questions` - 获取练习题目列表 (支持分页和类型过滤)
|
||||
- `GET /api/practice/questions/random` - 获取随机练习题目
|
||||
- `GET /api/practice/questions/:id` - 获取指定练习题目
|
||||
- `POST /api/practice/submit` - 提交练习答案 (简答题自动AI评分)
|
||||
- `POST /api/practice/submit` - 提交练习答案
|
||||
- `GET /api/practice/types` - 获取题型列表
|
||||
|
||||
#### 其他
|
||||
- `GET /api/health` - 健康检查端点
|
||||
@ -103,7 +105,6 @@ go build -o bin/server.exe main.go
|
||||
- 用户登录系统(基于PostgreSQL数据库)
|
||||
- 题目练习功能
|
||||
- 答题统计功能
|
||||
- **AI智能评分** - 简答题使用AI进行智能评分和反馈
|
||||
- React + TypeScript + Vite 前端
|
||||
- Ant Design Mobile UI组件库
|
||||
|
||||
@ -143,41 +144,21 @@ go build -o bin/server.exe main.go
|
||||
- `updated_at` - 更新时间
|
||||
- `deleted_at` - 软删除时间
|
||||
|
||||
### 数据导入
|
||||
|
||||
## AI评分配置
|
||||
首次运行项目需要导入练习题数据:
|
||||
|
||||
项目使用AI对简答题进行智能评分。AI服务配置位于 [pkg/config/config.go](pkg/config/config.go):
|
||||
|
||||
### 默认配置
|
||||
- **API地址**: https://ai.yuchat.top
|
||||
- **模型**: deepseek-v3
|
||||
- **评分方式**: 基于题目和标准答案,AI会给出分数(0-100)、评语和改进建议
|
||||
|
||||
### 环境变量配置(可选)
|
||||
可以通过环境变量覆盖默认配置:
|
||||
```bash
|
||||
export AI_BASE_URL="你的API地址"
|
||||
export AI_API_KEY="你的API密钥"
|
||||
export AI_MODEL="你的模型名称"
|
||||
# 确保 practice_question_pool.json 文件在项目根目录
|
||||
go run scripts/import_questions.go
|
||||
```
|
||||
|
||||
### AI评分返回格式
|
||||
对简答题提交答案时,响应会包含 `ai_grading` 字段:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"correct": true,
|
||||
"user_answer": "用户的答案",
|
||||
"correct_answer": "标准答案",
|
||||
"ai_grading": {
|
||||
"score": 85,
|
||||
"feedback": "答案基本正确,要点全面",
|
||||
"suggestion": "可以补充一些具体的例子"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
导入脚本会读取 [practice_question_pool.json](practice_question_pool.json) 文件并导入到数据库,共包含236道练习题,涵盖:
|
||||
- 填空题 (80道)
|
||||
- 判断题 (80道)
|
||||
- 单选题 (40道)
|
||||
- 多选题 (30道)
|
||||
- 简答题 (6道)
|
||||
|
||||
## 前端开发
|
||||
|
||||
@ -207,7 +188,7 @@ yarn build
|
||||
## 页面结构
|
||||
|
||||
- **登录页** (`/login`) - 用户登录和注册,支持密码可见性切换
|
||||
- **首页** (`/`) - 题型选择、错题本、题目列表等功能
|
||||
- **首页** (`/`) - 题目练习、随机题目、题目列表、筛选等功能
|
||||
- **我的** (`/profile`) - 用户信息、退出登录
|
||||
|
||||
## 特性
|
||||
@ -221,7 +202,7 @@ yarn build
|
||||
- 密码bcrypt加密存储
|
||||
- 练习题管理系统(236道练习题,5种题型)
|
||||
- 支持分页查询和题型筛选
|
||||
- **AI智能评分系统** - 使用deepseek-v3对简答题进行智能评分和反馈
|
||||
- 随机题目推送功能
|
||||
|
||||
### 前端特性
|
||||
- React + TypeScript + Vite 技术栈
|
||||
@ -239,7 +220,6 @@ yarn build
|
||||
- **GORM** v1.31.1 - ORM框架
|
||||
- **PostgreSQL** - 数据库
|
||||
- **bcrypt** - 密码加密
|
||||
- **go-openai** v1.41.2 - OpenAI SDK (用于AI评分)
|
||||
|
||||
### 前端
|
||||
- **React** 18 - UI框架
|
||||
|
||||
@ -1,38 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"ankao/pkg/config"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 直接连接数据库,不使用 InitDB
|
||||
cfg := config.GetDatabaseConfig()
|
||||
dsn := cfg.GetDSN()
|
||||
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Info),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal("数据库连接失败:", err)
|
||||
}
|
||||
|
||||
log.Println("开始清理 exam_shares 表...")
|
||||
|
||||
// 删除 exam_shares 表(如果存在)
|
||||
if err := db.Exec("DROP TABLE IF EXISTS exam_shares CASCADE").Error; err != nil {
|
||||
log.Fatal("删除 exam_shares 表失败:", err)
|
||||
}
|
||||
|
||||
log.Println("✓ 已删除 exam_shares 表")
|
||||
log.Println("\n清理完成!现在可以重新运行主程序。")
|
||||
fmt.Println("\n执行步骤:")
|
||||
fmt.Println("1. go run main.go # 这会自动创建正确的表结构")
|
||||
fmt.Println("2. 如果有旧的分享数据需要迁移,运行:")
|
||||
fmt.Println(" go run cmd/migrate_exam_shares.go")
|
||||
}
|
||||
@ -1,189 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"ankao/internal/database"
|
||||
"ankao/internal/models"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// OldExam 旧的试卷模型(用于迁移)
|
||||
type OldExam struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||
UserID uint `gorm:"not null;index"`
|
||||
Title string `gorm:"type:varchar(200);default:''"`
|
||||
TotalScore int `gorm:"not null;default:100"`
|
||||
Duration int `gorm:"not null;default:60"`
|
||||
PassScore int `gorm:"not null;default:60"`
|
||||
QuestionIDs []byte `gorm:"type:json"`
|
||||
Status string `gorm:"type:varchar(20);not null;default:'active'"`
|
||||
IsShared bool `gorm:"default:false"`
|
||||
SharedByID *uint `gorm:"index"`
|
||||
}
|
||||
|
||||
func (OldExam) TableName() string {
|
||||
return "exams"
|
||||
}
|
||||
|
||||
func main() {
|
||||
// 初始化数据库
|
||||
if err := database.InitDB(); err != nil {
|
||||
log.Fatal("数据库初始化失败:", err)
|
||||
}
|
||||
db := database.GetDB()
|
||||
|
||||
log.Println("开始迁移试卷分享数据...")
|
||||
|
||||
// 1. 查找所有被分享的试卷副本
|
||||
var sharedExams []OldExam
|
||||
if err := db.Where("is_shared = ? AND shared_by_id IS NOT NULL", true).
|
||||
Find(&sharedExams).Error; err != nil {
|
||||
log.Fatal("查询分享试卷失败:", err)
|
||||
}
|
||||
|
||||
log.Printf("找到 %d 份分享试卷副本", len(sharedExams))
|
||||
|
||||
if len(sharedExams) == 0 {
|
||||
log.Println("没有需要迁移的数据,退出。")
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 按 shared_by_id + question_ids 分组
|
||||
type ShareGroup struct {
|
||||
SharedByID uint
|
||||
QuestionIDs string
|
||||
Exams []OldExam
|
||||
}
|
||||
|
||||
groupMap := make(map[string]*ShareGroup)
|
||||
for _, exam := range sharedExams {
|
||||
if exam.SharedByID == nil {
|
||||
continue
|
||||
}
|
||||
key := fmt.Sprintf("%d_%s", *exam.SharedByID, string(exam.QuestionIDs))
|
||||
if group, exists := groupMap[key]; exists {
|
||||
group.Exams = append(group.Exams, exam)
|
||||
} else {
|
||||
groupMap[key] = &ShareGroup{
|
||||
SharedByID: *exam.SharedByID,
|
||||
QuestionIDs: string(exam.QuestionIDs),
|
||||
Exams: []OldExam{exam},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("分组后共 %d 组", len(groupMap))
|
||||
|
||||
successCount := 0
|
||||
failCount := 0
|
||||
|
||||
// 3. 处理每个分组
|
||||
for _, group := range groupMap {
|
||||
// 查找原始试卷
|
||||
var originalExam OldExam
|
||||
if err := db.Where("user_id = ? AND question_ids = ? AND is_shared = ?",
|
||||
group.SharedByID, group.QuestionIDs, false).
|
||||
First(&originalExam).Error; err != nil {
|
||||
log.Printf("未找到原始试卷: shared_by_id=%d, 跳过该组", group.SharedByID)
|
||||
failCount += len(group.Exams)
|
||||
continue
|
||||
}
|
||||
|
||||
// 开始事务
|
||||
tx := db.Begin()
|
||||
|
||||
migrationSuccess := true
|
||||
|
||||
// 4. 为每个分享副本创建关联记录
|
||||
for _, sharedExam := range group.Exams {
|
||||
// 创建分享记录
|
||||
share := models.ExamShare{
|
||||
ExamID: originalExam.ID,
|
||||
SharedByID: group.SharedByID,
|
||||
SharedToID: sharedExam.UserID,
|
||||
SharedAt: sharedExam.CreatedAt,
|
||||
}
|
||||
|
||||
if err := tx.Create(&share).Error; err != nil {
|
||||
log.Printf("创建分享记录失败: %v", err)
|
||||
tx.Rollback()
|
||||
failCount += len(group.Exams)
|
||||
migrationSuccess = false
|
||||
break
|
||||
}
|
||||
|
||||
// 5. 更新考试记录,将 exam_id 指向原始试卷
|
||||
if err := tx.Model(&models.ExamRecord{}).
|
||||
Where("exam_id = ?", sharedExam.ID).
|
||||
Update("exam_id", originalExam.ID).Error; err != nil {
|
||||
log.Printf("更新考试记录失败: %v", err)
|
||||
tx.Rollback()
|
||||
failCount += len(group.Exams)
|
||||
migrationSuccess = false
|
||||
break
|
||||
}
|
||||
|
||||
// 6. 软删除分享副本
|
||||
if err := tx.Delete(&sharedExam).Error; err != nil {
|
||||
log.Printf("删除分享副本失败: %v", err)
|
||||
tx.Rollback()
|
||||
failCount += len(group.Exams)
|
||||
migrationSuccess = false
|
||||
break
|
||||
}
|
||||
|
||||
if migrationSuccess {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
|
||||
// 提交事务
|
||||
if migrationSuccess {
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
log.Printf("提交事务失败: %v", err)
|
||||
failCount += len(group.Exams)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("迁移完成: 成功 %d, 失败 %d", successCount, failCount)
|
||||
|
||||
// 7. 验证迁移结果
|
||||
log.Println("\n开始验证迁移结果...")
|
||||
|
||||
// 统计 exam_shares 表记录数
|
||||
var shareCount int64
|
||||
db.Model(&models.ExamShare{}).Count(&shareCount)
|
||||
log.Printf("exam_shares 表记录数: %d", shareCount)
|
||||
|
||||
// 统计剩余的分享副本数(应该为0)
|
||||
var remainingSharedExams int64
|
||||
db.Model(&OldExam{}).Where("is_shared = ?", true).Count(&remainingSharedExams)
|
||||
log.Printf("剩余分享副本数: %d (应该为0)", remainingSharedExams)
|
||||
|
||||
// 检查是否有孤立的考试记录
|
||||
var orphanRecords int64
|
||||
db.Raw(`
|
||||
SELECT COUNT(*) FROM exam_records er
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM exams e WHERE e.id = er.exam_id AND e.deleted_at IS NULL
|
||||
)
|
||||
`).Scan(&orphanRecords)
|
||||
log.Printf("孤立的考试记录数: %d (应该为0)", orphanRecords)
|
||||
|
||||
if remainingSharedExams == 0 && orphanRecords == 0 {
|
||||
log.Println("\n✓ 迁移验证通过!")
|
||||
} else {
|
||||
log.Println("\n✗ 迁移验证失败,请检查数据!")
|
||||
}
|
||||
|
||||
log.Println("\n注意: 如果验证通过,可以考虑在未来某个时间点执行以下SQL移除旧字段:")
|
||||
log.Println(" ALTER TABLE exams DROP COLUMN is_shared;")
|
||||
log.Println(" ALTER TABLE exams DROP COLUMN shared_by_id;")
|
||||
}
|
||||
@ -1,185 +0,0 @@
|
||||
# 修复 practice_progress 表索引问题
|
||||
|
||||
## 问题描述
|
||||
|
||||
在测试中发现,除了单选题(multiple-choice)能正常插入进度数据外,其他类型的题目无法插入数据到 `practice_progress` 表。
|
||||
|
||||
## 问题原因
|
||||
|
||||
### 错误的索引定义
|
||||
|
||||
**之前的模型定义**(错误):
|
||||
```go
|
||||
type PracticeProgress struct {
|
||||
ID int64 `gorm:"primarykey" json:"id"`
|
||||
CurrentQuestionID int64 `gorm:"not null" json:"current_question_id"`
|
||||
UserID int64 `gorm:"not null;uniqueIndex:idx_user_question" json:"user_id"` // ❌ 只在 user_id 上建索引
|
||||
Type string `gorm:"type:varchar(255);not null" json:"type"`
|
||||
UserAnswerRecords datatypes.JSON `gorm:"type:jsonp" json:"answers"`
|
||||
}
|
||||
```
|
||||
|
||||
**问题**:
|
||||
- 唯一索引 `idx_user_question` 只在 `user_id` 字段上
|
||||
- 同一用户只能有一条进度记录
|
||||
- 当用户答第二种题型时,因为 `user_id` 重复,插入失败
|
||||
- 日志中应该会看到类似错误:`duplicate key value violates unique constraint "idx_user_question"`
|
||||
|
||||
### 正确的索引定义
|
||||
|
||||
**修复后的模型定义**(正确):
|
||||
```go
|
||||
type PracticeProgress struct {
|
||||
ID int64 `gorm:"primarykey" json:"id"`
|
||||
CurrentQuestionID int64 `gorm:"not null" json:"current_question_id"`
|
||||
UserID int64 `gorm:"not null;uniqueIndex:idx_user_type" json:"user_id"` // ✅ 联合索引
|
||||
Type string `gorm:"type:varchar(255);not null;uniqueIndex:idx_user_type" json:"type"` // ✅ 联合索引
|
||||
UserAnswerRecords datatypes.JSON `gorm:"type:jsonb" json:"answers"`
|
||||
}
|
||||
```
|
||||
|
||||
**改进**:
|
||||
- 唯一索引 `idx_user_type` 是 `(user_id, type)` 的联合索引
|
||||
- 同一用户可以有多条进度记录(每种题型一条)
|
||||
- 例如:用户1 可以有 `(1, "multiple-choice")` 和 `(1, "true-false")` 两条记录
|
||||
|
||||
## 解决方案
|
||||
|
||||
### 方案1:使用 GORM 自动迁移(推荐)
|
||||
|
||||
1. **停止当前服务**
|
||||
|
||||
2. **删除旧表并重建**(谨慎:会丢失所有进度数据)
|
||||
|
||||
连接到 PostgreSQL 数据库:
|
||||
```bash
|
||||
psql -U your_username -d your_database
|
||||
```
|
||||
|
||||
执行:
|
||||
```sql
|
||||
DROP TABLE IF EXISTS practice_progress;
|
||||
```
|
||||
|
||||
3. **重启服务,GORM 会自动创建正确的表结构**
|
||||
```bash
|
||||
.\bin\server.exe
|
||||
```
|
||||
|
||||
### 方案2:手动修复索引(保留数据)
|
||||
|
||||
1. **连接到 PostgreSQL 数据库**:
|
||||
```bash
|
||||
psql -U your_username -d your_database
|
||||
```
|
||||
|
||||
2. **手动执行 SQL**:
|
||||
```sql
|
||||
-- 删除旧索引
|
||||
DROP INDEX IF EXISTS idx_user_question;
|
||||
|
||||
-- 创建新索引
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_type ON practice_progress(user_id, type);
|
||||
```
|
||||
|
||||
3. **验证索引**:
|
||||
```sql
|
||||
SELECT indexname, indexdef
|
||||
FROM pg_indexes
|
||||
WHERE tablename = 'practice_progress';
|
||||
```
|
||||
|
||||
应该看到:
|
||||
```
|
||||
indexname | indexdef
|
||||
--------------|--------------------------------------------------
|
||||
idx_user_type | CREATE UNIQUE INDEX idx_user_type ON practice_progress USING btree (user_id, type)
|
||||
```
|
||||
|
||||
4. **检查现有数据是否有冲突**:
|
||||
```sql
|
||||
SELECT user_id, type, COUNT(*)
|
||||
FROM practice_progress
|
||||
GROUP BY user_id, type
|
||||
HAVING COUNT(*) > 1;
|
||||
```
|
||||
|
||||
如果有重复数据,需要手动清理:
|
||||
```sql
|
||||
-- 保留每组的最新记录,删除旧记录
|
||||
DELETE FROM practice_progress a
|
||||
WHERE id NOT IN (
|
||||
SELECT MAX(id)
|
||||
FROM practice_progress b
|
||||
WHERE a.user_id = b.user_id AND a.type = b.type
|
||||
);
|
||||
```
|
||||
|
||||
## 验证修复
|
||||
|
||||
### 1. 检查表结构
|
||||
```sql
|
||||
\d practice_progress
|
||||
```
|
||||
|
||||
应该看到:
|
||||
```
|
||||
Indexes:
|
||||
"practice_progress_pkey" PRIMARY KEY, btree (id)
|
||||
"idx_user_type" UNIQUE, btree (user_id, type)
|
||||
```
|
||||
|
||||
### 2. 测试插入不同题型
|
||||
|
||||
**测试步骤**:
|
||||
1. 登录系统
|
||||
2. 选择"单选题",答几道题
|
||||
3. 切换到"多选题",答几道题
|
||||
4. 切换到"判断题",答几道题
|
||||
|
||||
**检查数据库**:
|
||||
```sql
|
||||
SELECT id, user_id, type, current_question_id
|
||||
FROM practice_progress
|
||||
WHERE user_id = 1; -- 替换为你的用户ID
|
||||
```
|
||||
|
||||
应该看到多条记录:
|
||||
```
|
||||
id | user_id | type | current_question_id
|
||||
---|---------|---------------------|--------------------
|
||||
1 | 1 | multiple-choice | 157
|
||||
2 | 1 | multiple-selection | 45
|
||||
3 | 1 | true-false | 10
|
||||
```
|
||||
|
||||
### 3. 检查后端日志
|
||||
|
||||
如果之前有错误,应该不再看到类似日志:
|
||||
```
|
||||
保存练习进度失败: duplicate key value violates unique constraint "idx_user_question"
|
||||
```
|
||||
|
||||
## 其他注意事项
|
||||
|
||||
1. **JSONB vs JSONP**:
|
||||
- 修改了 `UserAnswerRecords` 的类型从 `jsonp` 改为 `jsonb`
|
||||
- `jsonb` 是正确的 PostgreSQL JSON 类型
|
||||
- 性能更好,支持索引
|
||||
|
||||
2. **数据备份**:
|
||||
- 在修改表结构前,建议备份数据:
|
||||
```bash
|
||||
pg_dump -U your_username -d your_database -t practice_progress > backup_practice_progress.sql
|
||||
```
|
||||
|
||||
3. **回滚方案**:
|
||||
- 如果需要回滚,可以恢复备份:
|
||||
```bash
|
||||
psql -U your_username -d your_database < backup_practice_progress.sql
|
||||
```
|
||||
|
||||
## 相关文件
|
||||
|
||||
- 模型定义:`internal/models/practice_progress.go`
|
||||
- 写入逻辑:`internal/handlers/practice_handler.go:356-387`
|
||||
9
go.mod
9
go.mod
@ -10,8 +10,6 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/baidubce/app-builder/go/appbuilder v1.1.1 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.14.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.4.0 // indirect
|
||||
@ -21,10 +19,8 @@ require (
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.28.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.7.6 // indirect
|
||||
@ -34,15 +30,12 @@ require (
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.55.0 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/sashabaranov/go-openai v1.41.2 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.uber.org/mock v0.6.0 // indirect
|
||||
@ -54,6 +47,4 @@ require (
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
golang.org/x/tools v0.38.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gorm.io/datatypes v1.2.7 // indirect
|
||||
gorm.io/driver/mysql v1.5.6 // indirect
|
||||
)
|
||||
|
||||
20
go.sum
20
go.sum
@ -1,7 +1,3 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/baidubce/app-builder/go/appbuilder v1.1.1 h1:mPfUGmQU/Vi4KRJca6m34rWH/YWuQWOiPLmjtVjPhuA=
|
||||
github.com/baidubce/app-builder/go/appbuilder v1.1.1/go.mod h1:mHOdSd9TJ52aiUbRE2rW1omu4A0U7H32xN39ED+etmE=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
|
||||
@ -27,9 +23,6 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
|
||||
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
@ -37,8 +30,6 @@ github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
@ -57,8 +48,6 @@ github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzh
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@ -74,10 +63,6 @@ github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
|
||||
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/sashabaranov/go-openai v1.41.2 h1:vfPRBZNMpnqu8ELsclWcAvF19lDNgh1t6TVfFFOPiSM=
|
||||
github.com/sashabaranov/go-openai v1.41.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
@ -119,12 +104,7 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/datatypes v1.2.7 h1:ww9GAhF1aGXZY3EB3cJPJ7//JiuQo7DlQA7NNlVaTdk=
|
||||
gorm.io/datatypes v1.2.7/go.mod h1:M2iO+6S3hhi4nAyYe444Pcb0dcIiOMJ7QHaUXxyiNZY=
|
||||
gorm.io/driver/mysql v1.5.6 h1:Ld4mkIickM+EliaQZQx3uOJDJHtrd70MxAUqWqlx3Y8=
|
||||
gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
|
||||
@ -20,8 +20,7 @@ func InitDB() error {
|
||||
|
||||
var err error
|
||||
DB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Info), // 开启SQL日志
|
||||
DisableForeignKeyConstraintWhenMigrating: true, // 迁移时禁用外键约束
|
||||
Logger: logger.Default.LogMode(logger.Info), // 开启SQL日志
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to database: %w", err)
|
||||
@ -33,14 +32,6 @@ func InitDB() error {
|
||||
err = DB.AutoMigrate(
|
||||
&models.User{},
|
||||
&models.PracticeQuestion{},
|
||||
&models.PracticeProgress{}, // 练习进度表
|
||||
&models.WrongQuestion{}, // 错题表
|
||||
&models.WrongQuestionHistory{}, // 错题历史表
|
||||
&models.UserAnswerRecord{}, // 用户答题记录表
|
||||
&models.Exam{}, // 考试表(试卷)
|
||||
&models.ExamShare{}, // 试卷分享关联表
|
||||
&models.ExamRecord{}, // 考试记录表
|
||||
&models.ExamUserAnswer{}, // 用户答案表
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to migrate database: %w", err)
|
||||
|
||||
@ -1,143 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"ankao/internal/database"
|
||||
"ankao/internal/models"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetAllUsersWithStats 获取所有用户及其答题统计(仅管理员可访问)
|
||||
func GetAllUsersWithStats(c *gin.Context) {
|
||||
db := database.GetDB()
|
||||
|
||||
// 查询所有用户及其答题统计
|
||||
var userStats []models.UserStats
|
||||
|
||||
// SQL查询:联合查询用户表和答题记录表
|
||||
query := `
|
||||
SELECT
|
||||
u.id as user_id,
|
||||
u.username,
|
||||
u.nickname,
|
||||
u.avatar,
|
||||
u.user_type,
|
||||
COALESCE(COUNT(ar.id), 0) as total_answers,
|
||||
COALESCE(SUM(CASE WHEN ar.is_correct = true THEN 1 ELSE 0 END), 0) as correct_count,
|
||||
COALESCE(SUM(CASE WHEN ar.is_correct = false THEN 1 ELSE 0 END), 0) as wrong_count,
|
||||
CASE
|
||||
WHEN COUNT(ar.id) > 0 THEN
|
||||
ROUND(CAST(CAST(SUM(CASE WHEN ar.is_correct = true THEN 1 ELSE 0 END) AS FLOAT) / COUNT(ar.id) * 100 AS NUMERIC), 2)
|
||||
ELSE 0
|
||||
END as accuracy,
|
||||
u.created_at,
|
||||
MAX(ar.answered_at) as last_answer_at
|
||||
FROM users u
|
||||
LEFT JOIN user_answer_records ar ON u.id = ar.user_id AND ar.deleted_at IS NULL
|
||||
WHERE u.deleted_at IS NULL
|
||||
GROUP BY u.id, u.username, u.nickname, u.avatar, u.user_type, u.created_at
|
||||
ORDER BY total_answers DESC, accuracy DESC
|
||||
`
|
||||
|
||||
if err := db.Raw(query).Scan(&userStats).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "获取用户统计数据失败",
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": userStats,
|
||||
})
|
||||
}
|
||||
|
||||
// GetUserDetailStats 获取指定用户的详细统计信息
|
||||
func GetUserDetailStats(c *gin.Context) {
|
||||
userID := c.Param("id")
|
||||
db := database.GetDB()
|
||||
|
||||
// 查询用户基本信息
|
||||
var user models.User
|
||||
if err := db.First(&user, userID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"success": false,
|
||||
"message": "用户不存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 查询用户答题统计
|
||||
var stats models.UserStats
|
||||
query := `
|
||||
SELECT
|
||||
u.id as user_id,
|
||||
u.username,
|
||||
u.nickname,
|
||||
u.avatar,
|
||||
u.user_type,
|
||||
COALESCE(COUNT(ar.id), 0) as total_answers,
|
||||
COALESCE(SUM(CASE WHEN ar.is_correct = true THEN 1 ELSE 0 END), 0) as correct_count,
|
||||
COALESCE(SUM(CASE WHEN ar.is_correct = false THEN 1 ELSE 0 END), 0) as wrong_count,
|
||||
CASE
|
||||
WHEN COUNT(ar.id) > 0 THEN
|
||||
ROUND(CAST(CAST(SUM(CASE WHEN ar.is_correct = true THEN 1 ELSE 0 END) AS FLOAT) / COUNT(ar.id) * 100 AS NUMERIC), 2)
|
||||
ELSE 0
|
||||
END as accuracy,
|
||||
u.created_at,
|
||||
MAX(ar.answered_at) as last_answer_at
|
||||
FROM users u
|
||||
LEFT JOIN user_answer_records ar ON u.id = ar.user_id AND ar.deleted_at IS NULL
|
||||
WHERE u.id = ? AND u.deleted_at IS NULL
|
||||
GROUP BY u.id, u.username, u.nickname, u.avatar, u.user_type, u.created_at
|
||||
`
|
||||
|
||||
if err := db.Raw(query, userID).Scan(&stats).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "获取用户统计数据失败",
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 查询按题型分类的统计
|
||||
var typeStats []struct {
|
||||
QuestionType string `json:"question_type"`
|
||||
QuestionTypeName string `json:"question_type_name"`
|
||||
TotalAnswers int `json:"total_answers"`
|
||||
CorrectCount int `json:"correct_count"`
|
||||
Accuracy float64 `json:"accuracy"`
|
||||
}
|
||||
|
||||
typeQuery := `
|
||||
SELECT
|
||||
pq.type as question_type,
|
||||
pq.type_name as question_type_name,
|
||||
COUNT(ar.id) as total_answers,
|
||||
SUM(CASE WHEN ar.is_correct = true THEN 1 ELSE 0 END) as correct_count,
|
||||
CASE
|
||||
WHEN COUNT(ar.id) > 0 THEN
|
||||
ROUND(CAST(CAST(SUM(CASE WHEN ar.is_correct = true THEN 1 ELSE 0 END) AS FLOAT) / COUNT(ar.id) * 100 AS NUMERIC), 2)
|
||||
ELSE 0
|
||||
END as accuracy
|
||||
FROM user_answer_records ar
|
||||
JOIN practice_questions pq ON ar.question_id = pq.id
|
||||
WHERE ar.user_id = ? AND ar.deleted_at IS NULL
|
||||
GROUP BY pq.type, pq.type_name
|
||||
ORDER BY total_answers DESC
|
||||
`
|
||||
|
||||
db.Raw(typeQuery, userID).Scan(&typeStats)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"user_info": stats,
|
||||
"type_stats": typeStats,
|
||||
},
|
||||
})
|
||||
}
|
||||
@ -1,269 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"ankao/internal/database"
|
||||
"ankao/internal/models"
|
||||
"ankao/internal/services"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
// ReGradeExam 公开的重新阅卷函数,可被外部调用
|
||||
func ReGradeExam(recordID uint, examID uint, userID uint) {
|
||||
gradeExam(recordID, examID, userID)
|
||||
}
|
||||
|
||||
// gradeExam 异步阅卷函数
|
||||
func gradeExam(recordID uint, examID uint, userID uint) {
|
||||
db := database.GetDB()
|
||||
|
||||
// 查询考试记录
|
||||
var record models.ExamRecord
|
||||
if err := db.Where("id = ?", recordID).First(&record).Error; err != nil {
|
||||
log.Printf("查询考试记录失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 查询试卷
|
||||
var exam models.Exam
|
||||
if err := db.Where("id = ?", examID).First(&exam).Error; err != nil {
|
||||
log.Printf("查询试卷失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 从 ExamUserAnswer 表读取所有答案
|
||||
var userAnswers []models.ExamUserAnswer
|
||||
if err := db.Where("exam_record_id = ?", recordID).Find(&userAnswers).Error; err != nil {
|
||||
log.Printf("查询用户答案失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为 map 格式方便查找
|
||||
answersMap := make(map[int64]interface{})
|
||||
for _, ua := range userAnswers {
|
||||
var answer interface{}
|
||||
if err := json.Unmarshal(ua.Answer, &answer); err != nil {
|
||||
log.Printf("解析答案失败: %v", err)
|
||||
continue
|
||||
}
|
||||
answersMap[ua.QuestionID] = answer
|
||||
}
|
||||
|
||||
// 解析题目ID列表
|
||||
var questionIDs []uint
|
||||
if err := json.Unmarshal(exam.QuestionIDs, &questionIDs); err != nil {
|
||||
log.Printf("解析题目ID失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 查询题目详情
|
||||
var questions []models.PracticeQuestion
|
||||
if err := db.Where("id IN ?", questionIDs).Find(&questions).Error; err != nil {
|
||||
log.Printf("查询题目失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 使用固定的题型分值映射
|
||||
scoreMap := map[string]float64{
|
||||
"fill-in-blank": 2.0, // 填空题每题2分
|
||||
"true-false": 1.0, // 判断题每题1分
|
||||
"multiple-choice": 1.0, // 单选题每题1分
|
||||
"multiple-selection": 2.0, // 多选题每题2分
|
||||
"short-answer": 10.0, // 简答题10分
|
||||
"ordinary-essay": 10.0, // 论述题10分
|
||||
"management-essay": 10.0, // 论述题10分
|
||||
}
|
||||
|
||||
// 评分
|
||||
totalScore := 0.0
|
||||
aiService, err := services.NewAIGradingService()
|
||||
if err != nil {
|
||||
log.Printf("AI服务初始化失败: %v,将跳过AI评分", err)
|
||||
// 不返回错误,继续评分流程,只是跳过AI评分
|
||||
}
|
||||
|
||||
for _, question := range questions {
|
||||
userAnswerRaw, answered := answersMap[question.ID]
|
||||
|
||||
if !answered {
|
||||
// 更新数据库中的 ExamUserAnswer 记录为未作答
|
||||
var userAnswer models.ExamUserAnswer
|
||||
result := db.Where("exam_record_id = ? AND question_id = ?", recordID, question.ID).First(&userAnswer)
|
||||
if result.Error == nil {
|
||||
updates := map[string]interface{}{
|
||||
"is_correct": false,
|
||||
"score": 0.0,
|
||||
}
|
||||
db.Model(&userAnswer).Updates(updates)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 根据题型判断答案
|
||||
var isCorrect bool
|
||||
var score float64
|
||||
var aiGrading *models.AIGrading
|
||||
|
||||
switch question.Type {
|
||||
case "fill-in-blank":
|
||||
// 填空题:比较数组
|
||||
userAnswerArr, ok := userAnswerRaw.([]interface{})
|
||||
if !ok {
|
||||
isCorrect = false
|
||||
score = 0
|
||||
// 更新数据库
|
||||
var userAnswer models.ExamUserAnswer
|
||||
if result := db.Where("exam_record_id = ? AND question_id = ?", recordID, question.ID).First(&userAnswer); result.Error == nil {
|
||||
db.Model(&userAnswer).Updates(map[string]interface{}{
|
||||
"is_correct": false,
|
||||
"score": 0.0,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
var correctAnswers []string
|
||||
if err := json.Unmarshal([]byte(question.AnswerData), &correctAnswers); err != nil {
|
||||
log.Printf("解析填空题答案失败: %v", err)
|
||||
continue
|
||||
}
|
||||
isCorrect = len(userAnswerArr) == len(correctAnswers)
|
||||
if isCorrect {
|
||||
for i, ua := range userAnswerArr {
|
||||
if i >= len(correctAnswers) || fmt.Sprintf("%v", ua) != correctAnswers[i] {
|
||||
isCorrect = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if isCorrect {
|
||||
score = scoreMap["fill-in-blank"]
|
||||
}
|
||||
|
||||
case "true-false":
|
||||
// 判断题 - AnswerData 直接存储 "true" 或 "false" 字符串
|
||||
correctAnswer := question.AnswerData
|
||||
isCorrect = fmt.Sprintf("%v", userAnswerRaw) == correctAnswer
|
||||
if isCorrect {
|
||||
score = scoreMap["true-false"]
|
||||
}
|
||||
|
||||
case "multiple-choice":
|
||||
correctAnswer := question.AnswerData
|
||||
isCorrect = fmt.Sprintf("\"%v\"", userAnswerRaw) == correctAnswer
|
||||
if isCorrect {
|
||||
score = scoreMap["multiple-choice"]
|
||||
}
|
||||
case "multiple-selection":
|
||||
// 多选题:比较数组(顺序无关)
|
||||
userAnswerArr, ok := userAnswerRaw.([]interface{})
|
||||
if !ok {
|
||||
isCorrect = false
|
||||
score = 0
|
||||
// 更新数据库
|
||||
var userAnswer models.ExamUserAnswer
|
||||
if result := db.Where("exam_record_id = ? AND question_id = ?", recordID, question.ID).First(&userAnswer); result.Error == nil {
|
||||
db.Model(&userAnswer).Updates(map[string]interface{}{
|
||||
"is_correct": false,
|
||||
"score": 0.0,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
var correctAnswers []string
|
||||
if err := json.Unmarshal([]byte(question.AnswerData), &correctAnswers); err != nil {
|
||||
log.Printf("解析多选题答案失败: %v", err)
|
||||
continue
|
||||
}
|
||||
userAnswerSet := make(map[string]bool)
|
||||
for _, ua := range userAnswerArr {
|
||||
userAnswerSet[fmt.Sprintf("%v", ua)] = true
|
||||
}
|
||||
isCorrect = len(userAnswerSet) == len(correctAnswers)
|
||||
if isCorrect {
|
||||
for _, ca := range correctAnswers {
|
||||
if !userAnswerSet[ca] {
|
||||
isCorrect = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if isCorrect {
|
||||
score = scoreMap["multiple-selection"]
|
||||
}
|
||||
|
||||
case "short-answer", "ordinary-essay", "management-essay":
|
||||
// 简答题和论述题:使用AI评分
|
||||
// AnswerData 直接存储答案文本
|
||||
correctAnswer := question.AnswerData
|
||||
userAnswerStr := fmt.Sprintf("%v", userAnswerRaw)
|
||||
|
||||
// 检查AI服务是否可用
|
||||
if aiService == nil {
|
||||
log.Printf("AI服务不可用,无法评分问题 %d", question.ID)
|
||||
isCorrect = false
|
||||
score = 0
|
||||
} else {
|
||||
aiResult, aiErr := aiService.GradeShortAnswer(question.Question, correctAnswer, userAnswerStr)
|
||||
if aiErr != nil {
|
||||
log.Printf("AI评分失败: %v", aiErr)
|
||||
isCorrect = false
|
||||
score = 0
|
||||
} else {
|
||||
isCorrect = aiResult.IsCorrect
|
||||
// 按AI评分比例计算
|
||||
var questionScore float64
|
||||
if question.Type == "short-answer" {
|
||||
questionScore = scoreMap["short-answer"]
|
||||
} else if question.Type == "ordinary-essay" {
|
||||
questionScore = scoreMap["ordinary-essay"]
|
||||
} else if question.Type == "management-essay" {
|
||||
questionScore = scoreMap["management-essay"]
|
||||
}
|
||||
score = questionScore * (aiResult.Score / 100.0)
|
||||
aiGrading = &models.AIGrading{
|
||||
Score: aiResult.Score,
|
||||
Feedback: aiResult.Feedback,
|
||||
Suggestion: aiResult.Suggestion,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
totalScore += score
|
||||
|
||||
// 更新数据库中的 ExamUserAnswer 记录
|
||||
var userAnswer models.ExamUserAnswer
|
||||
result := db.Where("exam_record_id = ? AND question_id = ?", recordID, question.ID).First(&userAnswer)
|
||||
if result.Error == nil {
|
||||
// 序列化 AI 评分数据
|
||||
var aiGradingJSON datatypes.JSON
|
||||
if aiGrading != nil {
|
||||
aiGradingData, _ := json.Marshal(aiGrading)
|
||||
aiGradingJSON = datatypes.JSON(aiGradingData)
|
||||
}
|
||||
|
||||
// 更新评分结果
|
||||
updates := map[string]interface{}{
|
||||
"is_correct": isCorrect,
|
||||
"score": score,
|
||||
"ai_grading_data": aiGradingJSON,
|
||||
}
|
||||
db.Model(&userAnswer).Updates(updates)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存分数和状态到考试记录
|
||||
record.Score = totalScore
|
||||
record.Status = "graded"
|
||||
record.IsPassed = totalScore >= float64(exam.PassScore)
|
||||
|
||||
if err := db.Save(&record).Error; err != nil {
|
||||
log.Printf("保存考试记录失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("阅卷完成: 考试记录ID=%d, 总分=%.2f, 是否通过=%v", recordID, totalScore, record.IsPassed)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,13 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"ankao/internal/database"
|
||||
"ankao/internal/models"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@ -26,182 +20,3 @@ func HealthCheckHandler(c *gin.Context) {
|
||||
"status": "healthy",
|
||||
})
|
||||
}
|
||||
|
||||
// GetDailyRanking 获取今日排行榜
|
||||
func GetDailyRanking(c *gin.Context) {
|
||||
db := database.GetDB()
|
||||
|
||||
// 获取查询参数
|
||||
limitStr := c.DefaultQuery("limit", "10")
|
||||
limit, err := strconv.Atoi(limitStr)
|
||||
if err != nil || limit <= 0 || limit > 100 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
// 查询今日排行榜(按答题数量和正确率排序)
|
||||
var rankings []models.UserStats
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
u.id as user_id,
|
||||
u.username,
|
||||
u.nickname,
|
||||
u.avatar,
|
||||
u.user_type,
|
||||
COALESCE(COUNT(ar.id), 0) as total_answers,
|
||||
COALESCE(SUM(CASE WHEN ar.is_correct = true THEN 1 ELSE 0 END), 0) as correct_count,
|
||||
COALESCE(SUM(CASE WHEN ar.is_correct = false THEN 1 ELSE 0 END), 0) as wrong_count,
|
||||
CASE
|
||||
WHEN COUNT(ar.id) > 0 THEN
|
||||
ROUND(CAST(CAST(SUM(CASE WHEN ar.is_correct = true THEN 1 ELSE 0 END) AS FLOAT) / COUNT(ar.id) * 100 AS NUMERIC), 2)
|
||||
ELSE 0
|
||||
END as accuracy,
|
||||
u.created_at,
|
||||
MAX(ar.answered_at) as last_answer_at
|
||||
FROM users u
|
||||
LEFT JOIN user_answer_records ar ON u.id = ar.user_id
|
||||
AND ar.deleted_at IS NULL
|
||||
AND DATE(ar.answered_at) = CURRENT_DATE
|
||||
WHERE u.deleted_at IS NULL
|
||||
GROUP BY u.id, u.username, u.nickname, u.avatar, u.user_type, u.created_at
|
||||
HAVING COUNT(ar.id) > 0
|
||||
ORDER BY total_answers DESC, accuracy DESC, correct_count DESC
|
||||
LIMIT ?
|
||||
`
|
||||
|
||||
if err := db.Raw(query, limit).Scan(&rankings).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "获取排行榜数据失败",
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": rankings,
|
||||
})
|
||||
}
|
||||
|
||||
// GetTotalRanking 获取总排行榜
|
||||
func GetTotalRanking(c *gin.Context) {
|
||||
db := database.GetDB()
|
||||
|
||||
// 获取查询参数
|
||||
limitStr := c.DefaultQuery("limit", "10")
|
||||
limit, err := strconv.Atoi(limitStr)
|
||||
if err != nil || limit <= 0 || limit > 100 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
// 查询总排行榜(按总答题数量和正确率排序)
|
||||
var rankings []models.UserStats
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
u.id as user_id,
|
||||
u.username,
|
||||
u.nickname,
|
||||
u.avatar,
|
||||
u.user_type,
|
||||
COALESCE(COUNT(ar.id), 0) as total_answers,
|
||||
COALESCE(SUM(CASE WHEN ar.is_correct = true THEN 1 ELSE 0 END), 0) as correct_count,
|
||||
COALESCE(SUM(CASE WHEN ar.is_correct = false THEN 1 ELSE 0 END), 0) as wrong_count,
|
||||
CASE
|
||||
WHEN COUNT(ar.id) > 0 THEN
|
||||
ROUND(CAST(CAST(SUM(CASE WHEN ar.is_correct = true THEN 1 ELSE 0 END) AS FLOAT) / COUNT(ar.id) * 100 AS NUMERIC), 2)
|
||||
ELSE 0
|
||||
END as accuracy,
|
||||
u.created_at,
|
||||
MAX(ar.answered_at) as last_answer_at
|
||||
FROM users u
|
||||
LEFT JOIN user_answer_records ar ON u.id = ar.user_id AND ar.deleted_at IS NULL
|
||||
WHERE u.deleted_at IS NULL
|
||||
GROUP BY u.id, u.username, u.nickname, u.avatar, u.user_type, u.created_at
|
||||
HAVING COUNT(ar.id) > 0
|
||||
ORDER BY total_answers DESC, accuracy DESC, correct_count DESC
|
||||
LIMIT ?
|
||||
`
|
||||
|
||||
if err := db.Raw(query, limit).Scan(&rankings).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "获取总排行榜数据失败",
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": rankings,
|
||||
})
|
||||
}
|
||||
|
||||
// StaticFileHandler 静态文件处理器,用于服务前端静态资源
|
||||
// 使用 NoRoute 避免与 API 路由冲突
|
||||
func StaticFileHandler(root string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// 获取请求路径
|
||||
path := r.URL.Path
|
||||
|
||||
// 构建完整文件路径
|
||||
fullPath := filepath.Join(root, path)
|
||||
|
||||
// 检查文件是否存在
|
||||
info, err := os.Stat(fullPath)
|
||||
if err != nil {
|
||||
// 文件不存在,尝试返回 index.html(SPA 应用)
|
||||
indexPath := filepath.Join(root, "index.html")
|
||||
if _, err := os.Stat(indexPath); err == nil {
|
||||
http.ServeFile(w, r, indexPath)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// 如果是目录,尝试返回目录下的 index.html
|
||||
if info.IsDir() {
|
||||
indexPath := filepath.Join(fullPath, "index.html")
|
||||
if _, err := os.Stat(indexPath); err == nil {
|
||||
http.ServeFile(w, r, indexPath)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// 设置正确的 Content-Type
|
||||
setContentType(w, fullPath)
|
||||
|
||||
// 返回文件
|
||||
http.ServeFile(w, r, fullPath)
|
||||
})
|
||||
}
|
||||
|
||||
// setContentType 根据文件扩展名设置正确的 Content-Type
|
||||
func setContentType(w http.ResponseWriter, filePath string) {
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
contentTypes := map[string]string{
|
||||
".html": "text/html; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".js": "application/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".ttf": "font/ttf",
|
||||
".eot": "application/vnd.ms-fontobject",
|
||||
}
|
||||
|
||||
if contentType, ok := contentTypes[ext]; ok {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -66,27 +66,13 @@ func Login(c *gin.Context) {
|
||||
// 生成token
|
||||
token := generateToken(req.Username)
|
||||
|
||||
// 保存token到数据库
|
||||
user.Token = token
|
||||
if err := db.Save(&user).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "token保存失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 返回用户信息(不包含密码)
|
||||
userInfo := models.UserInfoResponse{
|
||||
Username: user.Username,
|
||||
Avatar: user.Avatar,
|
||||
Nickname: user.Nickname,
|
||||
UserType: user.UserType, // 返回用户类型
|
||||
}
|
||||
|
||||
// 检查用户类型是否为空,如果为空,标识需要补充
|
||||
needUserType := user.UserType == ""
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "登录成功",
|
||||
@ -94,7 +80,6 @@ func Login(c *gin.Context) {
|
||||
Token: token,
|
||||
User: userInfo,
|
||||
},
|
||||
"need_user_type": needUserType, // 添加标识,前端根据此标识显示补充弹窗
|
||||
})
|
||||
}
|
||||
|
||||
@ -128,7 +113,6 @@ func Register(c *gin.Context) {
|
||||
newUser := models.User{
|
||||
Username: req.Username,
|
||||
Nickname: req.Nickname,
|
||||
UserType: req.UserType, // 保存用户类型
|
||||
Avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=" + req.Username, // 使用用户名生成默认头像
|
||||
}
|
||||
|
||||
@ -147,12 +131,6 @@ func Register(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 生成token
|
||||
token := generateToken(req.Username)
|
||||
|
||||
// 设置token
|
||||
newUser.Token = token
|
||||
|
||||
// 保存到数据库
|
||||
if err := db.Create(&newUser).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
@ -163,12 +141,14 @@ func Register(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 生成token
|
||||
token := generateToken(req.Username)
|
||||
|
||||
// 返回用户信息
|
||||
userInfo := models.UserInfoResponse{
|
||||
Username: newUser.Username,
|
||||
Avatar: newUser.Avatar,
|
||||
Nickname: newUser.Nickname,
|
||||
UserType: newUser.UserType, // 返回用户类型
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@ -180,209 +160,3 @@ func Register(c *gin.Context) {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateUserTypeRequest 更新用户类型请求
|
||||
type UpdateUserTypeRequest struct {
|
||||
UserType string `json:"user_type" binding:"required,oneof=ordinary-person management-person"`
|
||||
}
|
||||
|
||||
// UpdateUserType 更新用户类型
|
||||
func UpdateUserType(c *gin.Context) {
|
||||
var req UpdateUserTypeRequest
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "请求参数错误,用户类型必须是 ordinary-person 或 management-person",
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 从上下文获取用户信息(由认证中间件设置)
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "未授权访问",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
var user models.User
|
||||
if err := db.Where("username = ?", username).First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"success": false,
|
||||
"message": "用户不存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 更新用户类型
|
||||
user.UserType = req.UserType
|
||||
if err := db.Save(&user).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "更新用户类型失败",
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 返回更新后的用户信息
|
||||
userInfo := models.UserInfoResponse{
|
||||
Username: user.Username,
|
||||
Avatar: user.Avatar,
|
||||
Nickname: user.Nickname,
|
||||
UserType: user.UserType,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "用户类型更新成功",
|
||||
"data": userInfo,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateProfileRequest 更新用户信息请求
|
||||
type UpdateProfileRequest struct {
|
||||
Nickname string `json:"nickname" binding:"required"`
|
||||
UserType string `json:"user_type" binding:"required,oneof=ordinary-person management-person"`
|
||||
}
|
||||
|
||||
// UpdateProfile 更新用户信息
|
||||
func UpdateProfile(c *gin.Context) {
|
||||
var req UpdateProfileRequest
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "请求参数错误",
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 从上下文获取用户信息(由认证中间件设置)
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "未授权访问",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
var user models.User
|
||||
if err := db.Where("username = ?", username).First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"success": false,
|
||||
"message": "用户不存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 更新用户信息
|
||||
user.Nickname = req.Nickname
|
||||
user.UserType = req.UserType
|
||||
if err := db.Save(&user).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "更新用户信息失败",
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 返回更新后的用户信息
|
||||
userInfo := models.UserInfoResponse{
|
||||
Username: user.Username,
|
||||
Avatar: user.Avatar,
|
||||
Nickname: user.Nickname,
|
||||
UserType: user.UserType,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "用户信息更新成功",
|
||||
"data": userInfo,
|
||||
})
|
||||
}
|
||||
|
||||
// ChangePasswordRequest 修改密码请求
|
||||
type ChangePasswordRequest struct {
|
||||
OldPassword string `json:"old_password" binding:"required"`
|
||||
NewPassword string `json:"new_password" binding:"required,min=6"`
|
||||
}
|
||||
|
||||
// ChangePassword 修改密码
|
||||
func ChangePassword(c *gin.Context) {
|
||||
var req ChangePasswordRequest
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "请求参数错误,新密码长度至少为6位",
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 从上下文获取用户信息(由认证中间件设置)
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "未授权访问",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
var user models.User
|
||||
if err := db.Where("username = ?", username).First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"success": false,
|
||||
"message": "用户不存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证旧密码
|
||||
if !user.CheckPassword(req.OldPassword) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "当前密码错误",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 更新密码
|
||||
if err := user.HashPassword(req.NewPassword); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "密码加密失败",
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 清除旧的token,强制重新登录
|
||||
user.Token = ""
|
||||
|
||||
if err := db.Save(&user).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "密码更新失败",
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "密码修改成功,请重新登录",
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,323 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"ankao/internal/database"
|
||||
"ankao/internal/models"
|
||||
"ankao/internal/services"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ==================== 错题管理 API ====================
|
||||
|
||||
// GetWrongQuestions 获取错题列表(新版)
|
||||
// GET /api/v2/wrong-questions?is_mastered=false&type=single-choice&tag=数学&sort=review_time
|
||||
func GetWrongQuestions(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
query := db.Model(&models.WrongQuestion{}).Where("user_id = ?", userID)
|
||||
|
||||
// 筛选条件
|
||||
if isMastered := c.Query("is_mastered"); isMastered != "" {
|
||||
query = query.Where("is_mastered = ?", isMastered == "true")
|
||||
}
|
||||
|
||||
// 排序
|
||||
switch c.Query("sort") {
|
||||
case "wrong_count":
|
||||
// 按错误次数排序(错误最多的在前)
|
||||
query = query.Order("total_wrong_count DESC")
|
||||
case "mastery_level":
|
||||
// 按掌握度排序(掌握度最低的在前)
|
||||
query = query.Order("mastery_level ASC")
|
||||
default:
|
||||
// 默认按最后错误时间排序
|
||||
query = query.Order("last_wrong_time DESC")
|
||||
}
|
||||
|
||||
var wrongQuestions []models.WrongQuestion
|
||||
// 先查询错题记录
|
||||
if err := query.Find(&wrongQuestions).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询错题失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 手动加载关联数据
|
||||
for i := range wrongQuestions {
|
||||
// 加载题目信息(确保使用正确的关联)
|
||||
var practiceQuestion models.PracticeQuestion
|
||||
if err := db.Where("id = ?", wrongQuestions[i].QuestionID).First(&practiceQuestion).Error; err == nil {
|
||||
wrongQuestions[i].PracticeQuestion = &practiceQuestion
|
||||
}
|
||||
|
||||
// 加载最近3次历史
|
||||
var history []models.WrongQuestionHistory
|
||||
if err := db.Where("wrong_question_id = ?", wrongQuestions[i].ID).
|
||||
Order("answered_at DESC").
|
||||
Limit(3).
|
||||
Find(&history).Error; err == nil {
|
||||
wrongQuestions[i].History = history
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为 DTO
|
||||
dtos := make([]models.WrongQuestionDTO, len(wrongQuestions))
|
||||
for i, wq := range wrongQuestions {
|
||||
dtos[i] = convertWrongQuestionToDTO(&wq, true) // 包含最近历史
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": dtos,
|
||||
})
|
||||
}
|
||||
|
||||
// GetWrongQuestionDetail 获取错题详情(包含完整历史)
|
||||
// GET /api/v2/wrong-questions/:id
|
||||
func GetWrongQuestionDetail(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"})
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的错题ID"})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
var wrongQuestion models.WrongQuestion
|
||||
if err := db.Where("id = ? AND user_id = ?", id, userID).
|
||||
Preload("PracticeQuestion").
|
||||
Preload("History", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Order("answered_at DESC")
|
||||
}).
|
||||
First(&wrongQuestion).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "错题不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为 DTO(包含完整历史)
|
||||
dto := convertToDetailDTO(&wrongQuestion)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": dto,
|
||||
})
|
||||
}
|
||||
|
||||
// GetWrongQuestionStats 获取错题统计(新版)
|
||||
// GET /api/v2/wrong-questions/stats
|
||||
func GetWrongQuestionStats(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"})
|
||||
return
|
||||
}
|
||||
|
||||
stats, err := services.GetWrongQuestionStats(userID.(int64))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取统计失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": stats,
|
||||
})
|
||||
}
|
||||
|
||||
// GetRecommendedWrongQuestions 获取推荐练习的错题(智能推荐)
|
||||
// GET /api/v2/wrong-questions/recommended?limit=10&exclude=123
|
||||
func GetRecommendedWrongQuestions(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"})
|
||||
return
|
||||
}
|
||||
|
||||
limit := 10
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
|
||||
// 获取要排除的题目ID(前端传递当前题目ID,避免重复推荐)
|
||||
excludeQuestionID := int64(0)
|
||||
if e := c.Query("exclude"); e != "" {
|
||||
if parsed, err := strconv.ParseUint(e, 10, 64); err == nil {
|
||||
excludeQuestionID = int64(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
questions, err := services.GetRecommendedWrongQuestions(userID.(int64), limit, excludeQuestionID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取推荐错题失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为 DTO
|
||||
dtos := make([]models.WrongQuestionDTO, len(questions))
|
||||
for i, wq := range questions {
|
||||
dtos[i] = convertWrongQuestionToDTO(&wq, false)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": dtos,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteWrongQuestion 删除错题(新版)
|
||||
// DELETE /api/v2/wrong-questions/:id
|
||||
func DeleteWrongQuestion(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"})
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的错题ID"})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
|
||||
// 删除历史记录
|
||||
db.Where("wrong_question_id = ?", id).Delete(&models.WrongQuestionHistory{})
|
||||
|
||||
// 删除错题记录
|
||||
result := db.Where("id = ? AND user_id = ?", id, userID).Delete(&models.WrongQuestion{})
|
||||
if result.Error != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除错题失败"})
|
||||
return
|
||||
}
|
||||
|
||||
if result.RowsAffected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "错题不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "删除成功",
|
||||
})
|
||||
}
|
||||
|
||||
// ClearWrongQuestions 清空错题本(新版)
|
||||
// DELETE /api/v2/wrong-questions
|
||||
func ClearWrongQuestions(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
|
||||
// 获取所有错题ID
|
||||
var wrongQuestionIDs []uint
|
||||
db.Model(&models.WrongQuestion{}).Where("user_id = ?", userID).Pluck("id", &wrongQuestionIDs)
|
||||
|
||||
// 删除历史记录
|
||||
if len(wrongQuestionIDs) > 0 {
|
||||
db.Where("wrong_question_id IN ?", wrongQuestionIDs).Delete(&models.WrongQuestionHistory{})
|
||||
}
|
||||
|
||||
// 删除错题记录
|
||||
if err := db.Where("user_id = ?", userID).Delete(&models.WrongQuestion{}).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "清空错题本失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "错题本已清空",
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 辅助函数 ====================
|
||||
|
||||
// convertWrongQuestionToDTO 转换为 DTO V2(可选是否包含最近历史)
|
||||
func convertWrongQuestionToDTO(wq *models.WrongQuestion, includeHistory bool) models.WrongQuestionDTO {
|
||||
dto := models.WrongQuestionDTO{
|
||||
ID: wq.ID,
|
||||
QuestionID: wq.QuestionID,
|
||||
FirstWrongTime: wq.FirstWrongTime,
|
||||
LastWrongTime: wq.LastWrongTime,
|
||||
TotalWrongCount: wq.TotalWrongCount,
|
||||
MasteryLevel: wq.MasteryLevel,
|
||||
ConsecutiveCorrect: wq.ConsecutiveCorrect,
|
||||
IsMastered: wq.IsMastered,
|
||||
}
|
||||
|
||||
// 转换题目信息
|
||||
if wq.PracticeQuestion != nil {
|
||||
questionDTO := convertToDTO(*wq.PracticeQuestion)
|
||||
dto.Question = &questionDTO
|
||||
}
|
||||
|
||||
// 包含最近3次历史
|
||||
if includeHistory && len(wq.History) > 0 {
|
||||
count := 3
|
||||
if len(wq.History) < count {
|
||||
count = len(wq.History)
|
||||
}
|
||||
dto.RecentHistory = make([]models.WrongQuestionHistoryDTO, count)
|
||||
for i := 0; i < count; i++ {
|
||||
dto.RecentHistory[i] = convertWrongHistoryToDTO(&wq.History[i])
|
||||
}
|
||||
}
|
||||
|
||||
return dto
|
||||
}
|
||||
|
||||
// convertToDetailDTO 转换为详情 DTO(包含完整历史)
|
||||
func convertToDetailDTO(wq *models.WrongQuestion) models.WrongQuestionDTO {
|
||||
dto := convertWrongQuestionToDTO(wq, false)
|
||||
|
||||
// 包含完整历史
|
||||
if len(wq.History) > 0 {
|
||||
dto.RecentHistory = make([]models.WrongQuestionHistoryDTO, len(wq.History))
|
||||
for i, h := range wq.History {
|
||||
dto.RecentHistory[i] = convertWrongHistoryToDTO(&h)
|
||||
}
|
||||
}
|
||||
|
||||
return dto
|
||||
}
|
||||
|
||||
// convertWrongHistoryToDTO 转换历史记录为 DTO
|
||||
func convertWrongHistoryToDTO(h *models.WrongQuestionHistory) models.WrongQuestionHistoryDTO {
|
||||
return models.WrongQuestionHistoryDTO{
|
||||
ID: h.ID,
|
||||
UserAnswer: parseJSONAnswer(h.UserAnswer),
|
||||
CorrectAnswer: parseJSONAnswer(h.CorrectAnswer),
|
||||
AnsweredAt: h.AnsweredAt,
|
||||
TimeSpent: h.TimeSpent,
|
||||
IsCorrect: h.IsCorrect,
|
||||
}
|
||||
}
|
||||
|
||||
// parseJSONAnswer 解析 JSON 答案
|
||||
func parseJSONAnswer(answerStr string) interface{} {
|
||||
var answer interface{}
|
||||
if err := json.Unmarshal([]byte(answerStr), &answer); err != nil {
|
||||
return answerStr
|
||||
}
|
||||
return answer
|
||||
}
|
||||
@ -1,36 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AdminOnly 管理员权限验证中间件(仅yanlongqi用户可访问)
|
||||
func AdminOnly() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 从上下文中获取用户名(需要先通过Auth中间件)
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "未登录",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否是管理员用户(仅yanlongqi)
|
||||
if username != "yanlongqi" {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": "无权访问,该功能仅限管理员使用",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 权限验证通过,继续处理请求
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@ -1,85 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"ankao/internal/database"
|
||||
"ankao/internal/models"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Auth 认证中间件
|
||||
func Auth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 从请求头获取token
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "未登录",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 解析Bearer token
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "token格式错误",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
token := parts[1]
|
||||
|
||||
// 从数据库查找token对应的用户
|
||||
db := database.GetDB()
|
||||
var user models.User
|
||||
if err := db.Where("token = ?", token).First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "token无效或已过期",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 将用户ID设置到上下文
|
||||
c.Set("user_id", user.ID)
|
||||
c.Set("username", user.Username)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// AdminAuth 管理员认证中间件(必须在Auth中间件之后使用)
|
||||
func AdminAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 获取用户名(由 Auth 中间件设置)
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "未登录",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否为管理员用户
|
||||
if username != "yanlongqi" {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": "无权限访问",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@ -1,35 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"gorm.io/datatypes"
|
||||
"time"
|
||||
)
|
||||
|
||||
// UserAnswerRecord 用户答题记录
|
||||
type UserAnswerRecord struct {
|
||||
ID int64 `gorm:"primarykey"`
|
||||
UserID int64 `gorm:"index;not null" json:"user_id"` // 用户ID
|
||||
QuestionID int64 `gorm:"index;not null" json:"question_id"` // 题目ID
|
||||
IsCorrect bool `gorm:"not null" json:"is_correct"` // 是否答对
|
||||
AnsweredAt time.Time `gorm:"not null" json:"answered_at"` // 答题时间
|
||||
UserAnswer datatypes.JSON `gorm:"json" json:"user_answer"`
|
||||
|
||||
// AI 评分相关字段(仅简答题有值)
|
||||
AIScore *float64 `gorm:"type:decimal(5,2)" json:"ai_score,omitempty"` // AI 评分 (0-100)
|
||||
AIFeedback *string `gorm:"type:text" json:"ai_feedback,omitempty"` // AI 评语
|
||||
AISuggestion *string `gorm:"type:text" json:"ai_suggestion,omitempty"` // AI 改进建议
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (UserAnswerRecord) TableName() string {
|
||||
return "user_answer_records"
|
||||
}
|
||||
|
||||
// UserStatistics 用户统计数据
|
||||
type UserStatistics struct {
|
||||
TotalQuestions int `json:"total_questions"` // 题库总数
|
||||
AnsweredQuestions int `json:"answered_questions"` // 已答题数
|
||||
CorrectAnswers int `json:"correct_answers"` // 答对题数
|
||||
WrongQuestions int `json:"wrong_questions"` // 错题数量
|
||||
Accuracy float64 `json:"accuracy"` // 正确率
|
||||
}
|
||||
@ -1,162 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Exam 试卷模型
|
||||
type Exam 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:"-"`
|
||||
UserID uint `gorm:"not null;index" json:"user_id"` // 创建者ID
|
||||
Title string `gorm:"type:varchar(200);default:''" json:"title"` // 试卷标题
|
||||
TotalScore int `gorm:"not null;default:100" json:"total_score"` // 总分
|
||||
Duration int `gorm:"not null;default:60" json:"duration"` // 考试时长(分钟)
|
||||
PassScore int `gorm:"not null;default:60" json:"pass_score"` // 及格分数
|
||||
QuestionIDs datatypes.JSON `gorm:"type:json" json:"question_ids"` // 题目ID列表 (JSON数组)
|
||||
Status string `gorm:"type:varchar(20);not null;default:'active'" json:"status"` // 状态: active, archived
|
||||
IsSystem bool `gorm:"default:false;index" json:"is_system"` // 是否为系统试卷
|
||||
|
||||
// 关联关系
|
||||
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 考试记录
|
||||
type ExamRecord struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
ExamID uint `gorm:"not null;index" json:"exam_id"` // 试卷ID
|
||||
UserID uint `gorm:"not null;index" json:"user_id"` // 考生ID
|
||||
StartTime *time.Time `json:"start_time"` // 开始时间
|
||||
SubmitTime *time.Time `json:"submit_time"` // 提交时间
|
||||
TimeSpent int `json:"time_spent"` // 实际用时(秒)
|
||||
Score float64 `gorm:"type:decimal(5,2)" json:"score"` // 得分
|
||||
TotalScore int `json:"total_score"` // 总分
|
||||
Status string `gorm:"type:varchar(20);not null;default:'in_progress'" json:"status"` // 状态: in_progress, submitted, graded
|
||||
IsPassed bool `json:"is_passed"` // 是否通过
|
||||
|
||||
// 关联
|
||||
Exam *Exam `gorm:"foreignKey:ExamID" json:"exam,omitempty"`
|
||||
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||
}
|
||||
|
||||
// ExamUserAnswer 用户答案表(记录每道题的答案)
|
||||
type ExamUserAnswer struct {
|
||||
ID int64 `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
ExamRecordID int64 `gorm:"not null;index:idx_record_question" json:"exam_record_id"` // 考试记录ID
|
||||
QuestionID int64 `gorm:"not null;index:idx_record_question" json:"question_id"` // 题目ID
|
||||
UserID int64 `gorm:"not null;index" json:"user_id"` // 用户ID
|
||||
Answer datatypes.JSON `gorm:"type:json" json:"answer"` // 用户答案 (JSON格式,支持各种题型)
|
||||
IsCorrect *bool `json:"is_correct,omitempty"` // 是否正确(提交后评分)
|
||||
Score float64 `gorm:"type:decimal(5,2);default:0" json:"score"` // 得分
|
||||
AIGradingData datatypes.JSON `gorm:"type:json" json:"ai_grading_data,omitempty"` // AI评分数据
|
||||
AnsweredAt *time.Time `json:"answered_at"` // 答题时间
|
||||
LastModifiedAt time.Time `json:"last_modified_at"` // 最后修改时间
|
||||
|
||||
// 关联
|
||||
ExamRecord *ExamRecord `gorm:"foreignKey:ExamRecordID" json:"-"`
|
||||
Question *PracticeQuestion `gorm:"foreignKey:QuestionID" json:"-"`
|
||||
}
|
||||
|
||||
// ExamConfig 试卷配置结构
|
||||
type ExamConfig struct {
|
||||
QuestionTypes []QuestionTypeConfig `json:"question_types"` // 题型配置
|
||||
Categories []string `json:"categories"` // 题目分类筛选
|
||||
Difficulty []string `json:"difficulty"` // 难度筛选
|
||||
RandomOrder bool `json:"random_order"` // 是否随机顺序
|
||||
}
|
||||
|
||||
// QuestionTypeConfig 题型配置
|
||||
type QuestionTypeConfig struct {
|
||||
Type string `json:"type"` // 题目类型
|
||||
Count int `json:"count"` // 题目数量
|
||||
Score float64 `json:"score"` // 每题分数
|
||||
}
|
||||
|
||||
// ExamAnswer 考试答案结构
|
||||
type ExamAnswer struct {
|
||||
QuestionID int64 `json:"question_id"`
|
||||
Answer interface{} `json:"answer"` // 用户答案
|
||||
CorrectAnswer interface{} `json:"correct_answer"` // 正确答案
|
||||
IsCorrect bool `json:"is_correct"`
|
||||
Score float64 `json:"score"`
|
||||
AIGrading *AIGrading `json:"ai_grading,omitempty"`
|
||||
}
|
||||
|
||||
// ExamQuestionConfig 考试题目配置
|
||||
type ExamQuestionConfig struct {
|
||||
FillInBlank int `json:"fill_in_blank"` // 填空题数量
|
||||
TrueFalse int `json:"true_false"` // 判断题数量
|
||||
MultipleChoice int `json:"multiple_choice"` // 单选题数量
|
||||
MultipleSelection int `json:"multiple_selection"` // 多选题数量
|
||||
ShortAnswer int `json:"short_answer"` // 简答题数量
|
||||
OrdinaryEssay int `json:"ordinary_essay"` // 普通涉密人员论述题数量
|
||||
ManagementEssay int `json:"management_essay"` // 保密管理人员论述题数量
|
||||
}
|
||||
|
||||
// DefaultExamConfig 默认考试配置
|
||||
var DefaultExamConfig = ExamQuestionConfig{
|
||||
FillInBlank: 10, // 填空题10道
|
||||
TrueFalse: 10, // 判断题10道
|
||||
MultipleChoice: 10, // 单选题10道
|
||||
MultipleSelection: 10, // 多选题10道
|
||||
ShortAnswer: 2, // 简答题2道
|
||||
OrdinaryEssay: 1, // 普通论述题1道
|
||||
ManagementEssay: 1, // 管理论述题1道
|
||||
}
|
||||
|
||||
// ExamScoreConfig 考试分值配置
|
||||
type ExamScoreConfig struct {
|
||||
FillInBlank float64 `json:"fill_in_blank"` // 填空题分值
|
||||
TrueFalse float64 `json:"true_false"` // 判断题分值
|
||||
MultipleChoice float64 `json:"multiple_choice"` // 单选题分值
|
||||
MultipleSelection float64 `json:"multiple_selection"` // 多选题分值
|
||||
Essay float64 `json:"essay"` // 论述题分值
|
||||
}
|
||||
|
||||
// DefaultScoreConfig 默认分值配置
|
||||
var DefaultScoreConfig = ExamScoreConfig{
|
||||
FillInBlank: 2.0, // 填空题每题2分 (共20分)
|
||||
TrueFalse: 2.0, // 判断题每题2分 (共20分)
|
||||
MultipleChoice: 1.0, // 单选题每题1分 (共10分)
|
||||
MultipleSelection: 2.5, // 多选题每题2.5分 (共25分)
|
||||
Essay: 25.0, // 论述题25分
|
||||
}
|
||||
@ -1,30 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ExamShare 试卷分享关联表
|
||||
type ExamShare struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
ExamID uint `gorm:"not null;uniqueIndex:uk_exam_shared_to" json:"exam_id"`
|
||||
SharedByID int64 `gorm:"not null;index" json:"shared_by_id"`
|
||||
SharedToID int64 `gorm:"not null;uniqueIndex:uk_exam_shared_to" json:"shared_to_id"`
|
||||
SharedAt time.Time `gorm:"not null;default:CURRENT_TIMESTAMP" json:"shared_at"`
|
||||
|
||||
// 关联关系
|
||||
Exam *Exam `gorm:"foreignKey:ExamID" json:"exam,omitempty"`
|
||||
SharedBy *User `gorm:"foreignKey:SharedByID;references:ID" json:"shared_by,omitempty"`
|
||||
SharedTo *User `gorm:"foreignKey:SharedToID;references:ID" json:"shared_to,omitempty"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (ExamShare) TableName() string {
|
||||
return "exam_shares"
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
// PracticeProgress 练习进度记录(每个用户每种题型一条记录)
|
||||
type PracticeProgress struct {
|
||||
ID int64 `gorm:"primarykey" json:"id"`
|
||||
CurrentQuestionID int64 `gorm:"not null" json:"current_question_id"`
|
||||
UserID int64 `gorm:"not null;uniqueIndex:idx_user_type" json:"user_id"`
|
||||
Type string `gorm:"type:varchar(255);not null;uniqueIndex:idx_user_type" json:"type"`
|
||||
UserAnswerRecords datatypes.JSON `gorm:"type:jsonb" json:"answers"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (PracticeProgress) TableName() string {
|
||||
return "practice_progress"
|
||||
}
|
||||
@ -1,14 +1,27 @@
|
||||
package models
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
// PracticeQuestionType 题目类型
|
||||
type PracticeQuestionType string
|
||||
|
||||
const (
|
||||
FillInBlank PracticeQuestionType = "fill-in-blank" // 填空题
|
||||
TrueFalseType PracticeQuestionType = "true-false" // 判断题
|
||||
MultipleChoiceQ PracticeQuestionType = "multiple-choice" // 单选题
|
||||
MultipleSelection PracticeQuestionType = "multiple-selection" // 多选题
|
||||
ShortAnswer PracticeQuestionType = "short-answer" // 简答题
|
||||
)
|
||||
|
||||
// PracticeQuestion 练习题目模型
|
||||
type PracticeQuestion struct {
|
||||
ID int64 `gorm:"primarykey"`
|
||||
QuestionID string `gorm:"size:50;not null" json:"question_id"` // 题目ID (原JSON中的id字段)
|
||||
Type string `gorm:"index;size:30;not null" json:"type"` // 题目类型
|
||||
TypeName string `gorm:"size:50" json:"type_name"` // 题型名称(中文)
|
||||
Question string `gorm:"type:text;not null" json:"question"` // 题目内容
|
||||
AnswerData string `gorm:"type:jsonb" json:"-"` // 答案数据(JSON格式存储)
|
||||
OptionsData string `gorm:"type:jsonb" json:"-"` // 选项数据(JSON格式存储,用于选择题)
|
||||
gorm.Model
|
||||
QuestionID string `gorm:"size:50;not null" json:"question_id"` // 题目ID (原JSON中的id字段)
|
||||
Type PracticeQuestionType `gorm:"index;size:30;not null" json:"type"` // 题目类型
|
||||
TypeName string `gorm:"size:50" json:"type_name"` // 题型名称(中文)
|
||||
Question string `gorm:"type:text;not null" json:"question"` // 题目内容
|
||||
AnswerData string `gorm:"type:text;not null" json:"-"` // 答案数据(JSON格式存储)
|
||||
OptionsData string `gorm:"type:text" json:"-"` // 选项数据(JSON格式存储,用于选择题)
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
@ -18,19 +31,16 @@ func (PracticeQuestion) TableName() string {
|
||||
|
||||
// PracticeQuestionDTO 用于前端返回的数据传输对象
|
||||
type PracticeQuestionDTO struct {
|
||||
ID int64 `json:"id"` // 数据库自增ID
|
||||
QuestionID string `json:"question_id"` // 题目编号(原JSON中的id)
|
||||
Type string `json:"type"` // 前端使用的简化类型: single, multiple, judge, fill
|
||||
Content string `json:"content"` // 题目内容
|
||||
Options []Option `json:"options"` // 选择题选项数组
|
||||
Category string `json:"category"` // 题目分类
|
||||
Answer interface{} `json:"answer"` // 正确答案(用于题目管理编辑)
|
||||
AnswerLengths []int `json:"answer_lengths,omitempty"` // 答案长度数组(用于打印时计算横线长度)
|
||||
ID uint `json:"id"`
|
||||
Type string `json:"type"` // 前端使用的简化类型: single, multiple, judge, fill
|
||||
Content string `json:"content"` // 题目内容
|
||||
Options []Option `json:"options"` // 选择题选项数组
|
||||
Category string `json:"category"` // 题目分类
|
||||
}
|
||||
|
||||
// PracticeAnswerSubmit 练习题答案提交
|
||||
type PracticeAnswerSubmit struct {
|
||||
QuestionID int64 `json:"question_id" binding:"required"` // 数据库ID
|
||||
QuestionID uint `json:"question_id" binding:"required"` // 数据库ID
|
||||
Answer interface{} `json:"answer" binding:"required"` // 用户答案
|
||||
}
|
||||
|
||||
@ -39,14 +49,4 @@ type PracticeAnswerResult struct {
|
||||
Correct bool `json:"correct"` // 是否正确
|
||||
UserAnswer interface{} `json:"user_answer"` // 用户答案
|
||||
CorrectAnswer interface{} `json:"correct_answer,omitempty"` // 正确答案(仅在错误时返回)
|
||||
AIGrading *AIGrading `json:"ai_grading,omitempty"` // AI评分结果(仅简答题)
|
||||
}
|
||||
|
||||
// AIGrading AI评分结果
|
||||
type AIGrading struct {
|
||||
Score float64 `json:"score"` // 得分 (0-100)
|
||||
Feedback string `json:"feedback"` // 评语
|
||||
Suggestion string `json:"suggestion"` // 改进建议
|
||||
ReferenceAnswer string `json:"reference_answer,omitempty"` // 参考答案(论述题)
|
||||
ScoringRationale string `json:"scoring_rationale,omitempty"` // 评分依据
|
||||
}
|
||||
|
||||
@ -9,13 +9,11 @@ import (
|
||||
|
||||
// User 用户结构
|
||||
type User struct {
|
||||
ID int64 `gorm:"primaryKey" json:"id"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Username string `gorm:"uniqueIndex;not null;size:50" json:"username"`
|
||||
Password string `gorm:"not null;size:255" json:"-"` // json:"-" 表示在JSON响应中不返回密码
|
||||
Token string `gorm:"size:255;index" json:"-"` // 用户登录token
|
||||
Avatar string `gorm:"size:255" json:"avatar"`
|
||||
Nickname string `gorm:"size:50" json:"nickname"`
|
||||
UserType string `gorm:"size:50" json:"user_type"` // 用户类型: ordinary-person 或 management-person
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
@ -48,7 +46,7 @@ type LoginRequest struct {
|
||||
|
||||
// LoginResponse 登录响应
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
Token string `json:"token"`
|
||||
User UserInfoResponse `json:"user"`
|
||||
}
|
||||
|
||||
@ -57,7 +55,6 @@ type UserInfoResponse struct {
|
||||
Username string `json:"username"`
|
||||
Avatar string `json:"avatar"`
|
||||
Nickname string `json:"nickname"`
|
||||
UserType string `json:"user_type"` // 用户类型
|
||||
}
|
||||
|
||||
// RegisterRequest 注册请求
|
||||
@ -65,5 +62,4 @@ type RegisterRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
Nickname string `json:"nickname"`
|
||||
UserType string `json:"user_type" binding:"required,oneof=ordinary-person management-person"` // 用户类型,必填
|
||||
}
|
||||
|
||||
@ -1,16 +0,0 @@
|
||||
package models
|
||||
|
||||
// UserStats 用户统计信息
|
||||
type UserStats struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"` // 姓名
|
||||
Avatar string `json:"avatar"` // 头像
|
||||
UserType string `json:"user_type"` // 用户类型
|
||||
TotalAnswers int `json:"total_answers"` // 总答题数
|
||||
CorrectCount int `json:"correct_count"` // 答对数量
|
||||
WrongCount int `json:"wrong_count"` // 答错数量
|
||||
Accuracy float64 `json:"accuracy"` // 正确率(百分比)
|
||||
CreatedAt string `json:"created_at"` // 用户创建时间
|
||||
LastAnswerAt *string `json:"last_answer_at"` // 最后答题时间
|
||||
}
|
||||
@ -1,121 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// WrongQuestion 错题记录
|
||||
type WrongQuestion struct {
|
||||
ID int64 `gorm:"primarykey" json:"id"`
|
||||
UserID int64 `gorm:"index;not null" json:"user_id"`
|
||||
QuestionID int64 `gorm:"index;not null" json:"question_id"`
|
||||
FirstWrongTime time.Time `json:"first_wrong_time"`
|
||||
LastWrongTime time.Time `json:"last_wrong_time"`
|
||||
TotalWrongCount int `gorm:"default:1" json:"total_wrong_count"`
|
||||
MasteryLevel int `gorm:"default:0" json:"mastery_level"` // 0-100
|
||||
ConsecutiveCorrect int `gorm:"default:0" json:"consecutive_correct"`
|
||||
IsMastered bool `gorm:"default:false" json:"is_mastered"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
// 关联
|
||||
PracticeQuestion *PracticeQuestion `gorm:"foreignKey:QuestionID;references:ID" json:"question,omitempty"`
|
||||
History []WrongQuestionHistory `gorm:"foreignKey:WrongQuestionID" json:"history,omitempty"`
|
||||
}
|
||||
|
||||
// WrongQuestionHistory 错误历史记录
|
||||
type WrongQuestionHistory struct {
|
||||
ID int64 `gorm:"primarykey" json:"id"`
|
||||
WrongQuestionID int64 `gorm:"index;not null" json:"wrong_question_id"`
|
||||
UserAnswer string `gorm:"type:jsonb;not null" json:"user_answer"` // JSON 存储
|
||||
CorrectAnswer string `gorm:"type:jsonb;not null" json:"correct_answer"` // JSON 存储
|
||||
AnsweredAt time.Time `gorm:"index" json:"answered_at"`
|
||||
TimeSpent int `json:"time_spent"` // 答题用时(秒)
|
||||
IsCorrect bool `json:"is_correct"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (WrongQuestion) TableName() string {
|
||||
return "wrong_questions"
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (WrongQuestionHistory) TableName() string {
|
||||
return "wrong_question_history"
|
||||
}
|
||||
|
||||
// WrongQuestionDTO 错题数据传输对象
|
||||
type WrongQuestionDTO struct {
|
||||
ID int64 `json:"id"`
|
||||
QuestionID int64 `json:"question_id"`
|
||||
Question *PracticeQuestionDTO `json:"question"`
|
||||
FirstWrongTime time.Time `json:"first_wrong_time"`
|
||||
LastWrongTime time.Time `json:"last_wrong_time"`
|
||||
TotalWrongCount int `json:"total_wrong_count"`
|
||||
MasteryLevel int `json:"mastery_level"`
|
||||
ConsecutiveCorrect int `json:"consecutive_correct"`
|
||||
IsMastered bool `json:"is_mastered"`
|
||||
RecentHistory []WrongQuestionHistoryDTO `json:"recent_history,omitempty"` // 最近3次历史
|
||||
}
|
||||
|
||||
// WrongQuestionHistoryDTO 错误历史 DTO
|
||||
type WrongQuestionHistoryDTO struct {
|
||||
ID int64 `json:"id"`
|
||||
UserAnswer interface{} `json:"user_answer"`
|
||||
CorrectAnswer interface{} `json:"correct_answer"`
|
||||
AnsweredAt time.Time `json:"answered_at"`
|
||||
TimeSpent int `json:"time_spent"`
|
||||
IsCorrect bool `json:"is_correct"`
|
||||
}
|
||||
|
||||
// WrongQuestionStats 错题统计
|
||||
type WrongQuestionStats struct {
|
||||
TotalWrong int `json:"total_wrong"` // 总错题数
|
||||
Mastered int `json:"mastered"` // 已掌握数
|
||||
NotMastered int `json:"not_mastered"` // 未掌握数
|
||||
NeedReview int `json:"need_review"` // 需要复习数
|
||||
TypeStats map[string]int `json:"type_stats"` // 按题型统计
|
||||
CategoryStats map[string]int `json:"category_stats"` // 按分类统计
|
||||
MasteryLevelDist map[string]int `json:"mastery_level_dist"` // 掌握度分布
|
||||
TrendData []TrendPoint `json:"trend_data"` // 错题趋势
|
||||
}
|
||||
|
||||
// TrendPoint 趋势数据点
|
||||
type TrendPoint struct {
|
||||
Date string `json:"date"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// RecordWrongAnswer 记录错误答案
|
||||
func (wq *WrongQuestion) RecordWrongAnswer() {
|
||||
now := time.Now()
|
||||
if wq.FirstWrongTime.IsZero() {
|
||||
wq.FirstWrongTime = now
|
||||
}
|
||||
wq.LastWrongTime = now
|
||||
wq.TotalWrongCount++
|
||||
wq.ConsecutiveCorrect = 0 // 重置连续答对次数
|
||||
wq.IsMastered = false // 重新标记为未掌握
|
||||
wq.MasteryLevel = 0 // 重置掌握度
|
||||
}
|
||||
|
||||
// RecordCorrectAnswer 记录正确答案
|
||||
func (wq *WrongQuestion) RecordCorrectAnswer() {
|
||||
wq.ConsecutiveCorrect++
|
||||
|
||||
// 根据连续答对次数更新掌握度(每答对一次增加16.67%)
|
||||
// 连续答对6次即达到100%
|
||||
wq.MasteryLevel = (wq.ConsecutiveCorrect * 100) / 6
|
||||
if wq.MasteryLevel > 100 {
|
||||
wq.MasteryLevel = 100
|
||||
}
|
||||
|
||||
// 连续答对6次标记为已掌握
|
||||
if wq.ConsecutiveCorrect >= 6 {
|
||||
wq.IsMastered = true
|
||||
wq.MasteryLevel = 100
|
||||
}
|
||||
}
|
||||
@ -1,131 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AIGradingService AI评分服务接口(使用百度云AppBuilder)
|
||||
type AIGradingService struct {
|
||||
baiduService *BaiduAIGradingService
|
||||
}
|
||||
|
||||
// NewAIGradingService 创建AI评分服务实例
|
||||
func NewAIGradingService() (*AIGradingService, error) {
|
||||
baiduService, err := NewBaiduAIGradingService()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建百度云AI服务失败: %w", err)
|
||||
}
|
||||
|
||||
return &AIGradingService{
|
||||
baiduService: baiduService,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AIGradingResult AI评分结果
|
||||
type AIGradingResult struct {
|
||||
Score float64 `json:"score"` // 得分 (0-100)
|
||||
IsCorrect bool `json:"is_correct"` // 是否正确 (Score >= 60 视为正确)
|
||||
Feedback string `json:"feedback"` // 评语
|
||||
Suggestion string `json:"suggestion"` // 改进建议
|
||||
ReferenceAnswer string `json:"reference_answer"` // 参考答案(论述题)
|
||||
ScoringRationale string `json:"scoring_rationale"` // 评分依据
|
||||
}
|
||||
|
||||
// GradeEssay 对论述题进行AI评分(不需要标准答案)
|
||||
// question: 题目内容
|
||||
// userAnswer: 用户答案
|
||||
func (s *AIGradingService) GradeEssay(question, userAnswer string) (*AIGradingResult, error) {
|
||||
if s.baiduService == nil {
|
||||
return nil, fmt.Errorf("百度云AI服务未初始化")
|
||||
}
|
||||
return s.baiduService.GradeEssay(question, userAnswer)
|
||||
}
|
||||
|
||||
// GradeShortAnswer 对简答题进行AI评分
|
||||
// question: 题目内容
|
||||
// standardAnswer: 标准答案
|
||||
// userAnswer: 用户答案
|
||||
func (s *AIGradingService) GradeShortAnswer(question, standardAnswer, userAnswer string) (*AIGradingResult, error) {
|
||||
if s.baiduService == nil {
|
||||
return nil, fmt.Errorf("百度云AI服务未初始化")
|
||||
}
|
||||
return s.baiduService.GradeShortAnswer(question, standardAnswer, userAnswer)
|
||||
}
|
||||
|
||||
// AIExplanationResult AI解析结果
|
||||
type AIExplanationResult struct {
|
||||
Explanation string `json:"explanation"` // 题目解析
|
||||
}
|
||||
|
||||
// ExplainQuestionStream 生成题目解析(流式输出)
|
||||
// writer: HTTP响应写入器
|
||||
// question: 题目内容
|
||||
// standardAnswer: 标准答案
|
||||
// questionType: 题目类型
|
||||
func (s *AIGradingService) ExplainQuestionStream(writer http.ResponseWriter, question, standardAnswer, questionType string) error {
|
||||
if s.baiduService == nil {
|
||||
return fmt.Errorf("百度云AI服务未初始化")
|
||||
}
|
||||
return s.baiduService.ExplainQuestionStream(writer, question, standardAnswer, questionType)
|
||||
}
|
||||
|
||||
// ExplainQuestion 生成题目解析
|
||||
// question: 题目内容
|
||||
// standardAnswer: 标准答案
|
||||
// questionType: 题目类型
|
||||
func (s *AIGradingService) ExplainQuestion(question, standardAnswer, questionType string) (*AIExplanationResult, error) {
|
||||
if s.baiduService == nil {
|
||||
return nil, fmt.Errorf("百度云AI服务未初始化")
|
||||
}
|
||||
return s.baiduService.ExplainQuestion(question, standardAnswer, questionType)
|
||||
}
|
||||
|
||||
// parseAIResponse 解析AI返回的JSON响应
|
||||
func parseAIResponse(content string, result interface{}) error {
|
||||
// 移除可能的markdown代码块标记
|
||||
jsonStr := removeMarkdownCodeBlock(content)
|
||||
|
||||
// 使用json包解析
|
||||
if err := json.Unmarshal([]byte(jsonStr), result); err != nil {
|
||||
return fmt.Errorf("JSON解析失败: %w, 原始内容: %s", err, content)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeMarkdownCodeBlock 移除markdown代码块标记
|
||||
func removeMarkdownCodeBlock(s string) string {
|
||||
// 去除可能的```json和```标记
|
||||
s = strings.TrimSpace(s)
|
||||
|
||||
// 移除开头的```json或```
|
||||
if strings.HasPrefix(s, "```json") {
|
||||
s = s[7:]
|
||||
} else if strings.HasPrefix(s, "```") {
|
||||
s = s[3:]
|
||||
}
|
||||
|
||||
// 移除结尾的```
|
||||
if strings.HasSuffix(s, "```") {
|
||||
s = s[:len(s)-3]
|
||||
}
|
||||
|
||||
s = strings.TrimSpace(s)
|
||||
|
||||
// 查找第一个{的位置
|
||||
startIdx := strings.Index(s, "{")
|
||||
if startIdx == -1 {
|
||||
return s
|
||||
}
|
||||
|
||||
// 查找最后一个}的位置
|
||||
endIdx := strings.LastIndex(s, "}")
|
||||
if endIdx == -1 || endIdx <= startIdx {
|
||||
return s
|
||||
}
|
||||
|
||||
return s[startIdx : endIdx+1]
|
||||
}
|
||||
@ -1,388 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"ankao/pkg/config"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/baidubce/app-builder/go/appbuilder"
|
||||
)
|
||||
|
||||
// BaiduAIGradingService 百度云AI评分服务
|
||||
type BaiduAIGradingService struct {
|
||||
client *appbuilder.AppBuilderClient
|
||||
config *config.AIConfig
|
||||
conversationID string // 会话ID,用于保持上下文
|
||||
}
|
||||
|
||||
// 全局单例和锁
|
||||
var (
|
||||
globalBaiduService *BaiduAIGradingService
|
||||
serviceMutex sync.Mutex
|
||||
serviceInitialized bool
|
||||
)
|
||||
|
||||
// NewBaiduAIGradingService 创建百度云AI评分服务实例(使用单例模式)
|
||||
func NewBaiduAIGradingService() (*BaiduAIGradingService, error) {
|
||||
serviceMutex.Lock()
|
||||
defer serviceMutex.Unlock()
|
||||
|
||||
// 如果已经初始化过,直接返回
|
||||
if serviceInitialized && globalBaiduService != nil {
|
||||
return globalBaiduService, nil
|
||||
}
|
||||
|
||||
cfg := config.GetAIConfig()
|
||||
|
||||
// 设置百度云AppBuilder Token
|
||||
clientConfig, err := appbuilder.NewSDKConfig("", cfg.APIKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建SDK配置失败: %w", err)
|
||||
}
|
||||
|
||||
// 创建AppBuilder客户端
|
||||
client, err := appbuilder.NewAppBuilderClient(cfg.BaiduAppID, clientConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建AppBuilder客户端失败: %w", err)
|
||||
}
|
||||
|
||||
// 创建会话
|
||||
conversationID, err := client.CreateConversation()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建会话失败: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("百度云AI服务初始化成功,会话ID: %s", conversationID)
|
||||
|
||||
globalBaiduService = &BaiduAIGradingService{
|
||||
client: client,
|
||||
config: cfg,
|
||||
conversationID: conversationID,
|
||||
}
|
||||
serviceInitialized = true
|
||||
|
||||
return globalBaiduService, nil
|
||||
}
|
||||
|
||||
// GradeEssay 对论述题进行AI评分(不需要标准答案)
|
||||
func (s *BaiduAIGradingService) GradeEssay(question, userAnswer string) (*AIGradingResult, error) {
|
||||
prompt := fmt.Sprintf(`你是一位专业的保密领域阅卷老师,请对以下论述题进行评分。
|
||||
|
||||
题目:%s
|
||||
|
||||
学生答案:%s
|
||||
|
||||
评分标准(论述题没有固定标准答案,请根据答题质量和法规符合度评分):
|
||||
1. 论点是否明确,是否符合保密法规要求(30分)
|
||||
2. 内容是否充实,论据是否引用相关法规条文(30分)
|
||||
3. 逻辑是否严密,分析是否符合保密工作实际(25分)
|
||||
4. 语言表达是否准确、专业(15分)
|
||||
|
||||
评分等级:
|
||||
- 85-100分:论述优秀,论点明确、论据充分、符合法规要求、分析专业
|
||||
- 70-84分:论述良好,基本要素齐全,符合保密工作要求
|
||||
- 60-69分:论述基本合格,要点基本涵盖但不够深入
|
||||
- 40-59分:论述不够完整,缺乏法规支撑或逻辑性较差
|
||||
- 0-39分:论述严重缺失或完全离题
|
||||
|
||||
判断标准:60分及以上为正确(is_correct: true),否则为错误(is_correct: false)
|
||||
|
||||
评分要求:
|
||||
1. 给出一个0-100的精确分数
|
||||
2. 判断答案是否正确(is_correct: 60分及以上为true,否则为false)
|
||||
3. 生成一个专业的参考答案(reference_answer,150-300字,必须引用相关法规条文)
|
||||
4. 给出评分依据(scoring_rationale,说明依据了哪些法规和条文,80-150字)
|
||||
5. 给出简短的评语(feedback,说明得分情况,不超过80字)
|
||||
6. 给出具体的改进建议(suggestion,如果分数在90分以上可以简短,否则必须指出具体改进方向,不超过80字)
|
||||
|
||||
请按照以下JSON格式返回结果:
|
||||
{
|
||||
"score": 75,
|
||||
"is_correct": true,
|
||||
"reference_answer": "根据《中华人民共和国保守国家秘密法》第XX条...",
|
||||
"scoring_rationale": "依据《保密法》第XX条、《保密法实施条例》第XX条...",
|
||||
"feedback": "论述较为完整,论点明确,但论据不够充分,缺少具体法规引用",
|
||||
"suggestion": "建议补充《保密法》相关条文,加强论点之间的逻辑联系"
|
||||
}
|
||||
|
||||
注意:
|
||||
1. 只返回JSON格式的结果,不要有其他内容
|
||||
2. 参考答案必须专业、准确,体现保密法规要求
|
||||
3. 评分依据必须具体引用法规条文`, question, userAnswer)
|
||||
|
||||
// 调用百度云AI
|
||||
answer, err := s.runAppBuilder(prompt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 解析返回结果
|
||||
var result AIGradingResult
|
||||
if err := parseAIResponse(answer, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析AI响应失败: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GradeShortAnswer 对简答题进行AI评分
|
||||
func (s *BaiduAIGradingService) GradeShortAnswer(question, standardAnswer, userAnswer string) (*AIGradingResult, error) {
|
||||
prompt := fmt.Sprintf(`你是一位专业的保密领域阅卷老师,请严格按照标准答案对以下简答题进行评分。
|
||||
|
||||
题目:%s
|
||||
|
||||
标准答案:%s
|
||||
|
||||
学生答案:%s
|
||||
|
||||
评分依据:
|
||||
请依据以下保密法律法规和管理制度进行分析和评分:
|
||||
1. 《中华人民共和国保守国家秘密法》
|
||||
2. 《中华人民共和国保守国家秘密法实施条例》
|
||||
3. 《保密工作管理制度2025.9.9》
|
||||
4. 《软件开发管理制度》
|
||||
5. 《涉密信息系统集成资质保密标准》
|
||||
6. 《涉密信息系统集成资质管理办法》
|
||||
|
||||
评分标准(请严格遵守):
|
||||
1. 必须与标准答案进行逐项对比
|
||||
2. 答案要点完全覆盖标准答案且表述准确、符合法规要求的,给85-100分
|
||||
3. 答案要点基本覆盖但有缺漏或表述不够准确的,给60-84分
|
||||
4. 答案要点缺失较多或有明显错误的,给40-59分
|
||||
5. 答案完全错误或离题的,给0-39分
|
||||
6. 判断标准:60分及以上为正确(is_correct: true),否则为错误(is_correct: false)
|
||||
|
||||
评分要求:
|
||||
1. 给出一个0-100的精确分数
|
||||
2. 判断答案是否正确(is_correct: 60分及以上为true,否则为false)
|
||||
3. 给出评分依据(scoring_rationale,说明依据了哪些法规和标准答案的哪些要点,80-150字)
|
||||
4. 给出简短的评语(feedback,说明得分和失分原因,不超过80字)
|
||||
5. 给出具体的改进建议(suggestion,如果答案满分可以为空,否则必须指出具体改进方向,不超过80字)
|
||||
|
||||
请按照以下JSON格式返回结果:
|
||||
{
|
||||
"score": 85,
|
||||
"is_correct": true,
|
||||
"scoring_rationale": "依据《保密法》第XX条和标准答案要点分析...",
|
||||
"feedback": "答案覆盖了主要要点,但XXX部分描述不够准确",
|
||||
"suggestion": "建议补充XXX内容,并完善XXX的描述"
|
||||
}
|
||||
|
||||
注意:
|
||||
1. 只返回JSON格式的结果,不要有其他内容
|
||||
2. 必须严格对照标准答案评分,不要过于宽松
|
||||
3. 评分依据必须说明符合或违反了哪些法规要求`, question, standardAnswer, userAnswer)
|
||||
|
||||
// 调用百度云AI
|
||||
answer, err := s.runAppBuilder(prompt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 解析返回结果
|
||||
var result AIGradingResult
|
||||
if err := parseAIResponse(answer, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析AI响应失败: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// ExplainQuestionStream 生成题目解析(流式输出)
|
||||
func (s *BaiduAIGradingService) ExplainQuestionStream(writer http.ResponseWriter, question, standardAnswer, questionType string) error {
|
||||
prompt := fmt.Sprintf(`你是一位严谨、专业的保密领域专家老师,请对以下题目进行详细解析。
|
||||
|
||||
题目类型:%s
|
||||
|
||||
题目内容:%s
|
||||
|
||||
标准答案:%s
|
||||
|
||||
请提供一个详细的解析,要求:
|
||||
1. **必须基于保密法规**:解析时必须引用相关法规条文,说明依据哪些具体法律法规
|
||||
2. **必须实事求是**:只基于题目内容、标准答案和实际法规进行解析
|
||||
3. **不要胡编乱造**:如果某些信息不确定或题目没有提供,请如实说明,不要编造法规条文
|
||||
|
||||
解析内容要求:
|
||||
- **知识点**:说明题目考查的核心知识点,指出涉及哪些保密法规
|
||||
- **法规依据**:明确引用相关法律法规的具体条文(如:《保密法》第X条、《保密法实施条例》第X条等)
|
||||
- **解题思路**:提供清晰的解题步骤和方法,结合保密工作实际
|
||||
|
||||
%s
|
||||
|
||||
示例输出格式:
|
||||
## 知识点
|
||||
本题考查的是[知识点名称],涉及《XX法规》第XX条...
|
||||
|
||||
## 法规依据
|
||||
- 《中华人民共和国保守国家秘密法》第XX条规定:...
|
||||
- 《保密工作管理制度2025.9.9》第X章第X节:...
|
||||
|
||||
## 解题思路
|
||||
1. 首先根据《XX法规》第XX条,我们可以判断...
|
||||
2. 然后结合保密工作实际,分析...
|
||||
|
||||
%s
|
||||
|
||||
## 总结
|
||||
%s
|
||||
|
||||
**重要提醒**:请务必在解析中引用具体的法规条文,不要空泛地提及法规名称。如果不确定具体条文编号,可以说明法规的精神和要求。
|
||||
|
||||
请使用markdown格式输出解析内容。`,
|
||||
questionType,
|
||||
question,
|
||||
standardAnswer,
|
||||
// 根据题目类型添加特定要求
|
||||
func() string {
|
||||
if questionType == "single-selection" || questionType == "multiple-selection" {
|
||||
return `- **选项分析**:对于选择题,必须逐项分析每个选项的对错及原因,并说明依据哪些法规
|
||||
- **记忆口诀**:如果适用,提供便于记忆的口诀或技巧`
|
||||
}
|
||||
return "- **答案解析**:详细说明为什么这个答案是正确的,并引用相关法规依据"
|
||||
}(),
|
||||
// 根据题目类型添加示例格式
|
||||
func() string {
|
||||
if questionType == "single-selection" || questionType == "multiple-selection" {
|
||||
return `## 选项分析
|
||||
- **A选项**:[分析该选项],根据《XX法规》第XX条...
|
||||
- **B选项**:[分析该选项],根据《XX法规》第XX条...
|
||||
- **C选项**:[分析该选项],根据《XX法规》第XX条...
|
||||
- **D选项**:[分析该选项],根据《XX法规》第XX条...
|
||||
|
||||
## 正确答案
|
||||
正确答案是... 因为根据《XX法规》第XX条规定...`
|
||||
}
|
||||
return `## 答案解析
|
||||
正确答案是... 根据《XX法规》第XX条的规定...`
|
||||
}(),
|
||||
// 根据题目类型添加总结要求
|
||||
func() string {
|
||||
if questionType == "single-selection" || questionType == "multiple-selection" {
|
||||
return "对于选择题,可以提供记忆口诀或关键要点总结,并总结涉及的主要法规要求"
|
||||
}
|
||||
return "总结本题的关键要点、涉及的主要法规要求和在保密工作中的实际应用"
|
||||
}(),
|
||||
)
|
||||
|
||||
// 调用百度云AI(流式)
|
||||
return s.runAppBuilderStream(writer, prompt)
|
||||
}
|
||||
|
||||
// ExplainQuestion 生成题目解析
|
||||
func (s *BaiduAIGradingService) ExplainQuestion(question, standardAnswer, questionType string) (*AIExplanationResult, error) {
|
||||
prompt := fmt.Sprintf(`你是一位经验丰富的老师,请对以下题目进行详细解析。
|
||||
|
||||
题目类型:%s
|
||||
|
||||
题目内容:%s
|
||||
|
||||
标准答案:%s
|
||||
|
||||
请提供一个详细的解析,包括:
|
||||
1. 题目考查的知识点
|
||||
2. 解题思路和方法
|
||||
3. 为什么选择这个答案
|
||||
4. 相关的重要概念或注意事项
|
||||
|
||||
请按照以下JSON格式返回结果:
|
||||
{
|
||||
"explanation": "这道题考查的是...(200字以内的详细解析)"
|
||||
}
|
||||
|
||||
注意:只返回JSON格式的结果,不要有其他内容。`, questionType, question, standardAnswer)
|
||||
|
||||
// 调用百度云AI
|
||||
answer, err := s.runAppBuilder(prompt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 解析返回结果
|
||||
var result AIExplanationResult
|
||||
if err := parseAIResponse(answer, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析AI响应失败: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// runAppBuilder 调用百度云AppBuilder(非流式)
|
||||
func (s *BaiduAIGradingService) runAppBuilder(query string) (string, error) {
|
||||
startTime := time.Now()
|
||||
log.Printf("[百度云AI] 开始调用,会话ID: %s", s.conversationID)
|
||||
|
||||
// 调用AppBuilder Run方法 - 注意:即使stream=false,依然需要迭代读取
|
||||
iterator, err := s.client.Run(s.conversationID, query, nil, false)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("调用AppBuilder失败: %w", err)
|
||||
}
|
||||
|
||||
// 收集所有返回内容
|
||||
var fullAnswer strings.Builder
|
||||
|
||||
// 只读取一次 - 非流式模式下SDK应该一次性返回完整结果
|
||||
answer, err := iterator.Next()
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return "", fmt.Errorf("AI未返回任何内容")
|
||||
}
|
||||
return "", fmt.Errorf("读取AppBuilder响应失败: %w", err)
|
||||
}
|
||||
|
||||
if answer != nil && answer.Answer != "" {
|
||||
fullAnswer.WriteString(answer.Answer)
|
||||
}
|
||||
|
||||
result := fullAnswer.String()
|
||||
if result == "" {
|
||||
return "", fmt.Errorf("AI未返回任何内容")
|
||||
}
|
||||
|
||||
elapsed := time.Since(startTime)
|
||||
log.Printf("[百度云AI] 调用完成,耗时: %v, 返回长度: %d", elapsed, len(result))
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// runAppBuilderStream 调用百度云AppBuilder(流式)
|
||||
func (s *BaiduAIGradingService) runAppBuilderStream(writer http.ResponseWriter, query string) error {
|
||||
// 调用AppBuilder Run方法(流式)
|
||||
iterator, err := s.client.Run(s.conversationID, query, nil, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建流式请求失败: %w", err)
|
||||
}
|
||||
|
||||
flusher, ok := writer.(http.Flusher)
|
||||
if !ok {
|
||||
return fmt.Errorf("响应写入器不支持Flush")
|
||||
}
|
||||
|
||||
// 读取流式响应并发送给客户端 - 参考百度云SDK示例代码的方式
|
||||
var answer *appbuilder.AppBuilderClientAnswer
|
||||
for answer, err = iterator.Next(); err == nil; answer, err = iterator.Next() {
|
||||
// 发送增量内容
|
||||
if answer != nil && answer.Answer != "" {
|
||||
// 使用SSE格式发送
|
||||
fmt.Fprintf(writer, "data: %s\n\n", answer.Answer)
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否因为EOF之外的错误退出 - 使用errors.Is更安全
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return fmt.Errorf("接收流式响应失败: %w", err)
|
||||
}
|
||||
|
||||
// 流结束
|
||||
fmt.Fprintf(writer, "data: [DONE]\n\n")
|
||||
flusher.Flush()
|
||||
|
||||
return nil
|
||||
}
|
||||
@ -1,201 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"ankao/internal/database"
|
||||
"ankao/internal/models"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type DailyExamService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewDailyExamService() *DailyExamService {
|
||||
return &DailyExamService{
|
||||
db: database.GetDB(),
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateDailyExam 生成每日一练试卷
|
||||
func (s *DailyExamService) GenerateDailyExam() error {
|
||||
// 1. 获取分布式锁(使用日期作为锁ID)
|
||||
today := time.Now().Format("20060102")
|
||||
lockID := hashString(today) // 使用日期哈希作为锁ID
|
||||
|
||||
var locked bool
|
||||
if err := s.db.Raw("SELECT pg_try_advisory_lock(?)", lockID).Scan(&locked).Error; err != nil {
|
||||
return fmt.Errorf("获取锁失败: %w", err)
|
||||
}
|
||||
|
||||
if !locked {
|
||||
log.Println("其他实例正在生成每日一练,跳过")
|
||||
return nil
|
||||
}
|
||||
defer s.db.Exec("SELECT pg_advisory_unlock(?)", lockID)
|
||||
|
||||
// 2. 检查今天是否已生成
|
||||
todayStart := time.Now().Truncate(24 * time.Hour)
|
||||
todayEnd := todayStart.Add(24 * time.Hour)
|
||||
|
||||
var count int64
|
||||
s.db.Model(&models.Exam{}).
|
||||
Where("is_system = ? AND created_at >= ? AND created_at < ?",
|
||||
true, todayStart, todayEnd).
|
||||
Count(&count)
|
||||
|
||||
if count > 0 {
|
||||
log.Println("今日每日一练已生成,跳过")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 3. 生成试卷标题
|
||||
now := time.Now()
|
||||
title := fmt.Sprintf("%d年%02d月%02d日的每日一练",
|
||||
now.Year(), now.Month(), now.Day())
|
||||
|
||||
// 4. 随机选择题目(使用与创建试卷相同的逻辑)
|
||||
questionIDs, totalScore, err := s.selectQuestions()
|
||||
if err != nil {
|
||||
return fmt.Errorf("选择题目失败: %w", err)
|
||||
}
|
||||
|
||||
questionIDsJSON, _ := json.Marshal(questionIDs)
|
||||
|
||||
// 5. 创建试卷(使用第一个用户作为创建者,但标记为系统试卷)
|
||||
// 获取第一个用户ID
|
||||
var firstUser models.User
|
||||
if err := s.db.Order("id ASC").First(&firstUser).Error; err != nil {
|
||||
return fmt.Errorf("查询用户失败: %w", err)
|
||||
}
|
||||
|
||||
exam := models.Exam{
|
||||
UserID: uint(firstUser.ID), // 使用第一个用户作为创建者
|
||||
Title: title,
|
||||
TotalScore: int(totalScore),
|
||||
Duration: 60,
|
||||
PassScore: 80,
|
||||
QuestionIDs: questionIDsJSON,
|
||||
Status: "active",
|
||||
IsSystem: true, // 标记为系统试卷
|
||||
}
|
||||
|
||||
if err := s.db.Create(&exam).Error; err != nil {
|
||||
return fmt.Errorf("创建试卷失败: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("成功创建每日一练试卷: ID=%d, Title=%s", exam.ID, exam.Title)
|
||||
|
||||
// 6. 分享给所有用户
|
||||
if err := s.shareToAllUsers(exam.ID, uint(firstUser.ID)); err != nil {
|
||||
log.Printf("分享试卷失败: %v", err)
|
||||
// 不返回错误,因为试卷已创建成功
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// selectQuestions 选择题目(复用现有逻辑)
|
||||
func (s *DailyExamService) selectQuestions() ([]int64, float64, error) {
|
||||
questionTypes := []struct {
|
||||
Type string
|
||||
Count int
|
||||
Score float64
|
||||
}{
|
||||
{Type: "fill-in-blank", Count: 20, Score: 2.0}, // 40分
|
||||
{Type: "true-false", Count: 10, Score: 1.0}, // 10分
|
||||
{Type: "multiple-choice", Count: 10, Score: 1.0}, // 10分
|
||||
{Type: "multiple-selection", Count: 10, Score: 2.0}, // 20分
|
||||
{Type: "short-answer", Count: 1, Score: 10.0}, // 10分
|
||||
{Type: "ordinary-essay", Count: 1, Score: 10.0}, // 10分(普通涉密人员论述题)
|
||||
{Type: "management-essay", Count: 1, Score: 10.0}, // 10分(保密管理人员论述题)
|
||||
}
|
||||
|
||||
var allQuestionIDs []int64
|
||||
var totalScore float64
|
||||
|
||||
for _, qt := range questionTypes {
|
||||
var questions []models.PracticeQuestion
|
||||
if err := s.db.Where("type = ?", qt.Type).Find(&questions).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// 检查题目数量是否足够
|
||||
if len(questions) < qt.Count {
|
||||
return nil, 0, fmt.Errorf("题型 %s 题目数量不足,需要 %d 道,实际 %d 道",
|
||||
qt.Type, qt.Count, len(questions))
|
||||
}
|
||||
|
||||
// 随机抽取 (Fisher-Yates 洗牌算法)
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
for i := len(questions) - 1; i > 0; i-- {
|
||||
j := rand.Intn(i + 1)
|
||||
questions[i], questions[j] = questions[j], questions[i]
|
||||
}
|
||||
selectedQuestions := questions[:qt.Count]
|
||||
|
||||
for _, q := range selectedQuestions {
|
||||
allQuestionIDs = append(allQuestionIDs, q.ID)
|
||||
}
|
||||
|
||||
totalScore += float64(qt.Count) * qt.Score
|
||||
}
|
||||
|
||||
// 随机打乱题目ID顺序
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
for i := len(allQuestionIDs) - 1; i > 0; i-- {
|
||||
j := rand.Intn(i + 1)
|
||||
allQuestionIDs[i], allQuestionIDs[j] = allQuestionIDs[j], allQuestionIDs[i]
|
||||
}
|
||||
|
||||
return allQuestionIDs, totalScore, nil
|
||||
}
|
||||
|
||||
// shareToAllUsers 分享给所有用户
|
||||
func (s *DailyExamService) shareToAllUsers(examID uint, sharedByID uint) error {
|
||||
// 查询所有用户(排除创建者)
|
||||
var users []models.User
|
||||
if err := s.db.Where("id != ?", sharedByID).Find(&users).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 批量创建分享记录
|
||||
now := time.Now()
|
||||
shares := make([]models.ExamShare, 0, len(users))
|
||||
for _, user := range users {
|
||||
shares = append(shares, models.ExamShare{
|
||||
ExamID: examID,
|
||||
SharedByID: int64(sharedByID),
|
||||
SharedToID: int64(user.ID),
|
||||
SharedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
if len(shares) > 0 {
|
||||
// 批量插入
|
||||
if err := s.db.Create(&shares).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("成功分享给 %d 个用户", len(shares))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// hashString 计算字符串哈希值(用于生成锁ID)
|
||||
func hashString(s string) int64 {
|
||||
var hash int64
|
||||
for _, c := range s {
|
||||
hash = hash*31 + int64(c)
|
||||
}
|
||||
// 确保返回正数
|
||||
if hash < 0 {
|
||||
hash = -hash
|
||||
}
|
||||
return hash
|
||||
}
|
||||
@ -1,289 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"ankao/internal/database"
|
||||
"ankao/internal/models"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ==================== 错题服务 ====================
|
||||
|
||||
// RecordWrongAnswer 记录错误答案
|
||||
func RecordWrongAnswer(userID, questionID int64, userAnswer, correctAnswer interface{}, timeSpent int) error {
|
||||
db := database.GetDB()
|
||||
|
||||
log.Printf("[错题记录] 开始记录错题 (userID: %d, questionID: %d)", userID, questionID)
|
||||
|
||||
// 序列化答案
|
||||
userAnswerJSON, _ := json.Marshal(userAnswer)
|
||||
correctAnswerJSON, _ := json.Marshal(correctAnswer)
|
||||
|
||||
// 查找或创建错题记录
|
||||
var wrongQuestion models.WrongQuestion
|
||||
err := db.Where("user_id = ? AND question_id = ?", userID, questionID).First(&wrongQuestion).Error
|
||||
|
||||
if err != nil {
|
||||
// 不存在,创建新记录
|
||||
log.Printf("[错题记录] 创建新错题记录 (userID: %d, questionID: %d)", userID, questionID)
|
||||
wrongQuestion = models.WrongQuestion{
|
||||
UserID: userID,
|
||||
QuestionID: questionID,
|
||||
}
|
||||
wrongQuestion.RecordWrongAnswer()
|
||||
|
||||
if err := db.Create(&wrongQuestion).Error; err != nil {
|
||||
log.Printf("[错题记录] 创建错题记录失败: %v", err)
|
||||
return fmt.Errorf("创建错题记录失败: %v", err)
|
||||
}
|
||||
log.Printf("[错题记录] 成功创建错题记录 (ID: %d)", wrongQuestion.ID)
|
||||
} else {
|
||||
// 已存在,更新记录
|
||||
log.Printf("[错题记录] 更新已存在的错题记录 (ID: %d)", wrongQuestion.ID)
|
||||
wrongQuestion.RecordWrongAnswer()
|
||||
if err := db.Save(&wrongQuestion).Error; err != nil {
|
||||
log.Printf("[错题记录] 更新错题记录失败: %v", err)
|
||||
return fmt.Errorf("更新错题记录失败: %v", err)
|
||||
}
|
||||
log.Printf("[错题记录] 成功更新错题记录 (ID: %d, 错误次数: %d)", wrongQuestion.ID, wrongQuestion.TotalWrongCount)
|
||||
}
|
||||
|
||||
// 创建历史记录
|
||||
history := models.WrongQuestionHistory{
|
||||
WrongQuestionID: wrongQuestion.ID,
|
||||
UserAnswer: string(userAnswerJSON),
|
||||
CorrectAnswer: string(correctAnswerJSON),
|
||||
AnsweredAt: time.Now(),
|
||||
TimeSpent: timeSpent,
|
||||
IsCorrect: false,
|
||||
}
|
||||
|
||||
if err := db.Create(&history).Error; err != nil {
|
||||
log.Printf("[错题记录] 创建错题历史失败: %v", err)
|
||||
} else {
|
||||
log.Printf("[错题记录] 成功创建历史记录 (ID: %d, WrongQuestionID: %d)", history.ID, history.WrongQuestionID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecordCorrectAnswer 记录正确答案(用于错题练习)
|
||||
func RecordCorrectAnswer(userID, questionID int64, userAnswer, correctAnswer interface{}, timeSpent int) error {
|
||||
db := database.GetDB()
|
||||
|
||||
// 查找错题记录
|
||||
var wrongQuestion models.WrongQuestion
|
||||
err := db.Where("user_id = ? AND question_id = ?", userID, questionID).First(&wrongQuestion).Error
|
||||
if err != nil {
|
||||
// 不存在错题记录,无需处理
|
||||
return nil
|
||||
}
|
||||
|
||||
// 序列化答案
|
||||
userAnswerJSON, _ := json.Marshal(userAnswer)
|
||||
correctAnswerJSON, _ := json.Marshal(correctAnswer)
|
||||
|
||||
// 更新连续答对次数
|
||||
wrongQuestion.RecordCorrectAnswer()
|
||||
if err := db.Save(&wrongQuestion).Error; err != nil {
|
||||
return fmt.Errorf("更新错题记录失败: %v", err)
|
||||
}
|
||||
|
||||
// 创建历史记录
|
||||
history := models.WrongQuestionHistory{
|
||||
WrongQuestionID: wrongQuestion.ID,
|
||||
UserAnswer: string(userAnswerJSON),
|
||||
CorrectAnswer: string(correctAnswerJSON),
|
||||
AnsweredAt: time.Now(),
|
||||
TimeSpent: timeSpent,
|
||||
IsCorrect: true,
|
||||
}
|
||||
|
||||
if err := db.Create(&history).Error; err != nil {
|
||||
log.Printf("创建错题历史失败: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetWrongQuestionStats 获取错题统计
|
||||
func GetWrongQuestionStats(userID int64) (*models.WrongQuestionStats, error) {
|
||||
db := database.GetDB()
|
||||
|
||||
stats := &models.WrongQuestionStats{
|
||||
TypeStats: make(map[string]int),
|
||||
CategoryStats: make(map[string]int),
|
||||
MasteryLevelDist: make(map[string]int),
|
||||
}
|
||||
|
||||
// 基础统计
|
||||
var totalWrong, mastered int64
|
||||
db.Model(&models.WrongQuestion{}).Where("user_id = ?", userID).Count(&totalWrong)
|
||||
db.Model(&models.WrongQuestion{}).Where("user_id = ? AND is_mastered = ?", userID, true).Count(&mastered)
|
||||
|
||||
stats.TotalWrong = int(totalWrong)
|
||||
stats.Mastered = int(mastered)
|
||||
stats.NotMastered = int(totalWrong) - int(mastered)
|
||||
stats.NeedReview = 0 // 不再使用复习时间,设置为0
|
||||
|
||||
// 按题型统计
|
||||
var typeStats []struct {
|
||||
Type string
|
||||
Count int
|
||||
}
|
||||
db.Model(&models.WrongQuestion{}).
|
||||
Select("practice_questions.type, COUNT(*) as count").
|
||||
Joins("LEFT JOIN practice_questions ON practice_questions.id = wrong_questions.question_id").
|
||||
Where("wrong_questions.user_id = ?", userID).
|
||||
Group("practice_questions.type").
|
||||
Scan(&typeStats)
|
||||
|
||||
for _, ts := range typeStats {
|
||||
stats.TypeStats[ts.Type] = ts.Count
|
||||
}
|
||||
|
||||
// 按分类统计
|
||||
var categoryStats []struct {
|
||||
Category string
|
||||
Count int
|
||||
}
|
||||
db.Model(&models.WrongQuestion{}).
|
||||
Select("practice_questions.category, COUNT(*) as count").
|
||||
Joins("LEFT JOIN practice_questions ON practice_questions.id = wrong_questions.question_id").
|
||||
Where("wrong_questions.user_id = ?", userID).
|
||||
Group("practice_questions.category").
|
||||
Scan(&categoryStats)
|
||||
|
||||
for _, cs := range categoryStats {
|
||||
stats.CategoryStats[cs.Category] = cs.Count
|
||||
}
|
||||
|
||||
// 掌握度分布
|
||||
var masteryDist []struct {
|
||||
Level string
|
||||
Count int
|
||||
}
|
||||
db.Model(&models.WrongQuestion{}).
|
||||
Select(`
|
||||
CASE
|
||||
WHEN mastery_level >= 80 THEN '优秀'
|
||||
WHEN mastery_level >= 60 THEN '良好'
|
||||
WHEN mastery_level >= 40 THEN '一般'
|
||||
WHEN mastery_level >= 20 THEN '较差'
|
||||
ELSE '很差'
|
||||
END as level,
|
||||
COUNT(*) as count
|
||||
`).
|
||||
Where("user_id = ?", userID).
|
||||
Group("level").
|
||||
Scan(&masteryDist)
|
||||
|
||||
for _, md := range masteryDist {
|
||||
stats.MasteryLevelDist[md.Level] = md.Count
|
||||
}
|
||||
|
||||
// 错题趋势(最近7天)
|
||||
stats.TrendData = calculateTrendData(db, userID, 7)
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// calculateTrendData 计算错题趋势数据
|
||||
func calculateTrendData(db *gorm.DB, userID int64, days int) []models.TrendPoint {
|
||||
trendData := make([]models.TrendPoint, days)
|
||||
now := time.Now()
|
||||
|
||||
for i := days - 1; i >= 0; i-- {
|
||||
date := now.AddDate(0, 0, -i)
|
||||
dateStr := date.Format("01-02")
|
||||
|
||||
var count int64
|
||||
startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
|
||||
endOfDay := startOfDay.Add(24 * time.Hour)
|
||||
|
||||
db.Model(&models.WrongQuestion{}).
|
||||
Where("user_id = ? AND last_wrong_time >= ? AND last_wrong_time < ?", userID, startOfDay, endOfDay).
|
||||
Count(&count)
|
||||
|
||||
trendData[days-1-i] = models.TrendPoint{
|
||||
Date: dateStr,
|
||||
Count: int(count),
|
||||
}
|
||||
}
|
||||
|
||||
return trendData
|
||||
}
|
||||
|
||||
// GetRecommendedWrongQuestions 获取推荐练习的错题(智能推荐)
|
||||
// 推荐策略(按优先级):
|
||||
// 1. 最优先推荐掌握度为0的题目(从未答对过)
|
||||
// 2. 其次推荐掌握度低的题目(mastery_level 从低到高)
|
||||
// 3. 最后推荐最近答错的题目
|
||||
func GetRecommendedWrongQuestions(userID int64, limit int, excludeQuestionID int64) ([]models.WrongQuestion, error) {
|
||||
db := database.GetDB()
|
||||
|
||||
var questions []models.WrongQuestion
|
||||
|
||||
// 策略1: 最优先推荐掌握度为0的题目(从未答对过)
|
||||
var zeroMastery []models.WrongQuestion
|
||||
query1 := db.Where("user_id = ? AND is_mastered = ? AND mastery_level = 0", userID, false)
|
||||
if excludeQuestionID > 0 {
|
||||
query1 = query1.Where("question_id != ?", excludeQuestionID)
|
||||
}
|
||||
query1.Order("total_wrong_count DESC, last_wrong_time DESC").
|
||||
Limit(limit).
|
||||
Preload("PracticeQuestion").
|
||||
Find(&zeroMastery)
|
||||
questions = append(questions, zeroMastery...)
|
||||
|
||||
// 如果已经够了,直接返回
|
||||
if len(questions) >= limit {
|
||||
return questions[:limit], nil
|
||||
}
|
||||
|
||||
// 策略2: 推荐掌握度低的题目(mastery_level 从低到高)
|
||||
var lowMastery []models.WrongQuestion
|
||||
query2 := db.Where("user_id = ? AND is_mastered = ? AND mastery_level > 0 AND id NOT IN ?", userID, false, getIDs(questions))
|
||||
if excludeQuestionID > 0 {
|
||||
query2 = query2.Where("question_id != ?", excludeQuestionID)
|
||||
}
|
||||
query2.Order("mastery_level ASC, total_wrong_count DESC").
|
||||
Limit(limit - len(questions)).
|
||||
Preload("PracticeQuestion").
|
||||
Find(&lowMastery)
|
||||
questions = append(questions, lowMastery...)
|
||||
|
||||
if len(questions) >= limit {
|
||||
return questions[:limit], nil
|
||||
}
|
||||
|
||||
// 策略3: 最近答错的题目(填充剩余,以防万一)
|
||||
var recent []models.WrongQuestion
|
||||
query3 := db.Where("user_id = ? AND is_mastered = ? AND id NOT IN ?", userID, false, getIDs(questions))
|
||||
if excludeQuestionID > 0 {
|
||||
query3 = query3.Where("question_id != ?", excludeQuestionID)
|
||||
}
|
||||
query3.Order("last_wrong_time DESC").
|
||||
Limit(limit - len(questions)).
|
||||
Preload("PracticeQuestion").
|
||||
Find(&recent)
|
||||
questions = append(questions, recent...)
|
||||
|
||||
return questions, nil
|
||||
}
|
||||
|
||||
// getIDs 获取错题记录的ID列表
|
||||
func getIDs(questions []models.WrongQuestion) []int64 {
|
||||
if len(questions) == 0 {
|
||||
return []int64{0} // 避免 SQL 错误
|
||||
}
|
||||
ids := make([]int64, len(questions))
|
||||
for i, q := range questions {
|
||||
ids[i] = q.ID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@ -1,123 +0,0 @@
|
||||
kind: Deployment
|
||||
apiVersion: apps/v1
|
||||
metadata:
|
||||
name: ankao
|
||||
namespace: default
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: ankao
|
||||
replicas: 1
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: ankao
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: aliyun
|
||||
containers:
|
||||
- name: ankao
|
||||
image: registry.cn-qingdao.aliyuncs.com/yuchat/ankao:0.0.9
|
||||
env:
|
||||
- name: DB_HOST
|
||||
value: pgsql
|
||||
- name: DB_USERNAME
|
||||
value: postgres
|
||||
- name: DB_PASSWORD
|
||||
value: longqi@1314
|
||||
- name: AI_BASE_URL
|
||||
value: http://new-api
|
||||
- name: AI_API_KEY
|
||||
value: sk-OKBmOpJx855juSOPU14cWG6Iz87tZQuv3Xg9PiaJYXdHoKcN
|
||||
- name: AI_MODEL
|
||||
value: deepseek-v3
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: tcp-8080
|
||||
|
||||
# 存活探针 - 检测容器是否正在运行
|
||||
# 如果失败,Kubernetes 会重启容器
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 8080
|
||||
scheme: HTTP
|
||||
initialDelaySeconds: 30 # 容器启动后等待30秒再开始探测
|
||||
periodSeconds: 10 # 每10秒探测一次
|
||||
timeoutSeconds: 5 # 探测超时时间5秒
|
||||
successThreshold: 1 # 成功1次即认为成功
|
||||
failureThreshold: 3 # 连续失败3次后重启容器
|
||||
|
||||
# 就绪探针 - 检测容器是否准备好接收流量
|
||||
# 如果失败,会从 Service 负载均衡中移除
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 8080
|
||||
scheme: HTTP
|
||||
initialDelaySeconds: 10 # 容器启动后等待10秒再开始探测
|
||||
periodSeconds: 5 # 每5秒探测一次
|
||||
timeoutSeconds: 3 # 探测超时时间3秒
|
||||
successThreshold: 1 # 成功1次即认为就绪
|
||||
failureThreshold: 3 # 连续失败3次后标记为未就绪
|
||||
|
||||
# 启动探针 - 检测容器应用是否已经启动(可选)
|
||||
# 启动探针成功后,存活探针和就绪探针才会接管
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 8080
|
||||
scheme: HTTP
|
||||
initialDelaySeconds: 0 # 立即开始探测
|
||||
periodSeconds: 5 # 每5秒探测一次
|
||||
timeoutSeconds: 3 # 探测超时时间3秒
|
||||
successThreshold: 1 # 成功1次即认为启动完成
|
||||
failureThreshold: 12 # 最多失败12次(60秒)才判定启动失败
|
||||
|
||||
# 资源限制(建议配置)
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
|
||||
volumeMounts:
|
||||
- name: timezone
|
||||
mountPath: /etc/timezone
|
||||
readOnly: true
|
||||
- name: localtime
|
||||
mountPath: /etc/localtime
|
||||
readOnly: true
|
||||
|
||||
volumes:
|
||||
- name: timezone
|
||||
hostPath:
|
||||
path: /etc/timezone
|
||||
type: File
|
||||
- name: localtime
|
||||
hostPath:
|
||||
path: /etc/localtime
|
||||
type: File
|
||||
restartPolicy: Always
|
||||
|
||||
|
||||
---
|
||||
kind: Service
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
name: ankao
|
||||
namespace: default
|
||||
spec:
|
||||
selector:
|
||||
app: ankao
|
||||
ports:
|
||||
- name: tcp-80
|
||||
port: 80
|
||||
targetPort: 8080
|
||||
type: LoadBalancer
|
||||
ipFamilyPolicy: PreferDualStack
|
||||
ipFamilies:
|
||||
- IPv4
|
||||
- IPv6
|
||||
151
main.go
151
main.go
@ -4,13 +4,9 @@ import (
|
||||
"ankao/internal/database"
|
||||
"ankao/internal/handlers"
|
||||
"ankao/internal/middleware"
|
||||
"ankao/internal/services"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@ -27,7 +23,11 @@ func main() {
|
||||
r.Use(middleware.CORS())
|
||||
r.Use(middleware.Logger())
|
||||
|
||||
// API路由组(必须在静态文件服务之前注册)
|
||||
// 静态文件服务
|
||||
r.Static("/static", "./web/static")
|
||||
r.StaticFile("/", "./web/index.html")
|
||||
|
||||
// API路由组
|
||||
api := r.Group("/api")
|
||||
{
|
||||
// 健康检查
|
||||
@ -37,142 +37,17 @@ func main() {
|
||||
api.POST("/login", handlers.Login) // 用户登录
|
||||
api.POST("/register", handlers.Register) // 用户注册
|
||||
|
||||
// 需要认证的路由
|
||||
auth := api.Group("", middleware.Auth())
|
||||
{
|
||||
// 用户相关API
|
||||
auth.PUT("/user/type", handlers.UpdateUserType) // 更新用户类型
|
||||
auth.PUT("/user/profile", handlers.UpdateProfile) // 更新用户信息
|
||||
auth.PUT("/user/password", handlers.ChangePassword) // 修改密码
|
||||
|
||||
// 排行榜API
|
||||
auth.GET("/ranking/daily", handlers.GetDailyRanking) // 获取今日排行榜
|
||||
auth.GET("/ranking/total", handlers.GetTotalRanking) // 获取总排行榜
|
||||
|
||||
// 练习题相关API(需要登录)
|
||||
auth.GET("/practice/questions", handlers.GetPracticeQuestions) // 获取练习题目列表
|
||||
auth.GET("/practice/questions/:id", handlers.GetPracticeQuestionByID) // 获取指定练习题目
|
||||
auth.POST("/practice/explain", handlers.ExplainQuestion) // 生成题目解析(AI)
|
||||
|
||||
// 练习题提交(需要登录才能记录错题)
|
||||
auth.POST("/practice/submit", handlers.SubmitPracticeAnswer) // 提交练习答案
|
||||
auth.GET("/practice/statistics", handlers.GetStatistics) // 获取统计数据
|
||||
|
||||
// 练习进度相关API
|
||||
auth.GET("/practice/progress", handlers.GetPracticeProgress) // 获取练习进度
|
||||
auth.DELETE("/practice/progress", handlers.ClearPracticeProgress) // 清除练习进度
|
||||
|
||||
// 错题本相关API
|
||||
auth.GET("/wrong-questions", handlers.GetWrongQuestions) // 获取错题列表(支持筛选和排序)
|
||||
auth.GET("/wrong-questions/stats", handlers.GetWrongQuestionStats) // 获取错题统计(含趋势)
|
||||
auth.GET("/wrong-questions/recommended", handlers.GetRecommendedWrongQuestions) // 获取推荐错题
|
||||
auth.GET("/wrong-questions/:id", handlers.GetWrongQuestionDetail) // 获取错题详情
|
||||
auth.DELETE("/wrong-questions/:id", handlers.DeleteWrongQuestion) // 删除错题
|
||||
auth.DELETE("/wrong-questions", handlers.ClearWrongQuestions) // 清空错题本
|
||||
|
||||
// 模拟考试相关API
|
||||
auth.POST("/exams", handlers.CreateExam) // 创建试卷
|
||||
auth.GET("/exams", handlers.GetExamList) // 获取试卷列表
|
||||
auth.GET("/exams/:id", handlers.GetExamDetail) // 获取试卷详情
|
||||
auth.POST("/exams/:id/start", handlers.StartExam) // 开始考试
|
||||
auth.POST("/exam-records/:record_id/submit", handlers.SubmitExam) // 提交试卷答案
|
||||
auth.GET("/exam-records/:record_id", handlers.GetExamRecord) // 获取考试记录详情
|
||||
auth.GET("/exam-records", handlers.GetExamRecordList) // 获取考试记录列表
|
||||
auth.DELETE("/exams/:id", handlers.DeleteExam) // 删除试卷
|
||||
auth.POST("/exam-records/:record_id/progress", handlers.SaveExamProgress) // 保存考试进度
|
||||
auth.GET("/exam-records/:record_id/answers", handlers.GetExamUserAnswers) // 获取用户答案
|
||||
auth.GET("/users/shareable", handlers.GetShareableUsers) // 获取可分享的用户列表
|
||||
auth.POST("/exams/:id/share", handlers.ShareExam) // 分享试卷
|
||||
auth.GET("/daily-exam/ranking", handlers.GetDailyExamRanking) // 获取每日一练排行榜
|
||||
}
|
||||
|
||||
// 题库管理API(需要管理员权限)
|
||||
admin := api.Group("", middleware.Auth(), middleware.AdminAuth())
|
||||
{
|
||||
admin.POST("/practice/questions", handlers.CreatePracticeQuestion) // 创建题目
|
||||
admin.PUT("/practice/questions/:id", handlers.UpdatePracticeQuestion) // 更新题目
|
||||
admin.DELETE("/practice/questions/:id", handlers.DeletePracticeQuestion) // 删除题目
|
||||
}
|
||||
|
||||
// 用户管理API(仅yanlongqi用户可访问)
|
||||
userAdmin := api.Group("", middleware.Auth(), middleware.AdminOnly())
|
||||
{
|
||||
userAdmin.GET("/admin/users", handlers.GetAllUsersWithStats) // 获取所有用户及统计
|
||||
userAdmin.GET("/admin/users/:id", handlers.GetUserDetailStats) // 获取用户详细统计
|
||||
}
|
||||
// 练习题相关API
|
||||
api.GET("/practice/questions", handlers.GetPracticeQuestions) // 获取练习题目列表
|
||||
api.GET("/practice/questions/random", handlers.GetRandomPracticeQuestion) // 获取随机练习题目
|
||||
api.GET("/practice/questions/:id", handlers.GetPracticeQuestionByID) // 获取指定练习题目
|
||||
api.POST("/practice/submit", handlers.SubmitPracticeAnswer) // 提交练习答案
|
||||
api.GET("/practice/types", handlers.GetPracticeQuestionTypes) // 获取题型列表
|
||||
}
|
||||
|
||||
// 静态文件服务(使用 NoRoute 避免路由冲突)
|
||||
// 当没有匹配到任何 API 路由时,尝试提供静态文件
|
||||
r.NoRoute(gin.WrapH(handlers.StaticFileHandler("./web")))
|
||||
|
||||
// 创建自定义HTTP服务器,设置超时时间
|
||||
port := ":8080"
|
||||
server := &http.Server{
|
||||
Addr: port,
|
||||
Handler: r,
|
||||
ReadTimeout: 5 * time.Minute, // 读取超时:5分钟
|
||||
WriteTimeout: 5 * time.Minute, // 写入超时:5分钟
|
||||
IdleTimeout: 10 * time.Minute, // 空闲连接超时:10分钟
|
||||
MaxHeaderBytes: 1 << 20, // 最大请求头:1MB
|
||||
}
|
||||
|
||||
// 启动定时任务
|
||||
startCronJobs()
|
||||
|
||||
// 应用启动时检测并生成今日每日一练
|
||||
go checkAndGenerateDailyExam()
|
||||
|
||||
log.Printf("服务器启动在端口 %s,超时配置:读/写 5分钟", port)
|
||||
|
||||
// 启动服务器
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
port := ":8080"
|
||||
if err := r.Run(port); err != nil {
|
||||
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("每日一练检测完成")
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,8 +2,6 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// DatabaseConfig 数据库配置结构
|
||||
@ -16,51 +14,18 @@ type DatabaseConfig struct {
|
||||
SSLMode string
|
||||
}
|
||||
|
||||
// AIConfig AI服务配置结构
|
||||
type AIConfig struct {
|
||||
APIKey string
|
||||
BaiduAppID string // 百度云AppBuilder应用ID
|
||||
}
|
||||
|
||||
// GetDatabaseConfig 获取数据库配置
|
||||
// 优先使用环境变量,如果没有设置则使用默认值
|
||||
func GetDatabaseConfig() *DatabaseConfig {
|
||||
// 从环境变量获取配置,如果未设置则使用默认值
|
||||
host := getEnv("DB_HOST", "pgsql.yuchat.top")
|
||||
port := getEnvAsInt("DB_PORT", 5432)
|
||||
user := getEnv("DB_USER", "postgres")
|
||||
password := getEnv("DB_PASSWORD", "longqi@1314")
|
||||
dbname := getEnv("DB_NAME", "ankao")
|
||||
sslmode := getEnv("DB_SSLMODE", "disable")
|
||||
|
||||
return &DatabaseConfig{
|
||||
Host: host,
|
||||
Port: port,
|
||||
User: user,
|
||||
Password: password,
|
||||
DBName: dbname,
|
||||
SSLMode: sslmode,
|
||||
Host: "pgsql.yuchat.top",
|
||||
Port: 5432,
|
||||
User: "postgres",
|
||||
Password: "longqi@1314",
|
||||
DBName: "ankao",
|
||||
SSLMode: "disable",
|
||||
}
|
||||
}
|
||||
|
||||
// getEnv 获取环境变量,如果不存在则返回默认值
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// getEnvAsInt 获取整型环境变量,如果不存在或转换失败则返回默认值
|
||||
func getEnvAsInt(key string, defaultValue int) int {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
if intValue, err := strconv.Atoi(value); err == nil {
|
||||
return intValue
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// GetDSN 返回数据库连接字符串
|
||||
func (c *DatabaseConfig) GetDSN() string {
|
||||
return fmt.Sprintf(
|
||||
@ -73,15 +38,3 @@ func (c *DatabaseConfig) GetDSN() string {
|
||||
c.SSLMode,
|
||||
)
|
||||
}
|
||||
|
||||
// GetAIConfig 获取AI服务配置
|
||||
// 优先使用环境变量,如果没有设置则使用默认值
|
||||
func GetAIConfig() *AIConfig {
|
||||
apiKey := getEnv("AI_API_KEY", "bce-v3/ALTAK-TgZ1YSBmbwNXo3BIuzNZ2/768b777896453e820a2c46f38614c8e9bf43f845")
|
||||
baiduAppID := getEnv("BAIDU_APP_ID", "7b336aaf-f448-46d6-9e5f-bb9e38a1167c")
|
||||
|
||||
return &AIConfig{
|
||||
APIKey: apiKey,
|
||||
BaiduAppID: baiduAppID,
|
||||
}
|
||||
}
|
||||
|
||||
1587
practice_question_pool.json
Normal file
1587
practice_question_pool.json
Normal file
File diff suppressed because it is too large
Load Diff
113
scripts/import_questions.go
Normal file
113
scripts/import_questions.go
Normal file
@ -0,0 +1,113 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"ankao/internal/database"
|
||||
"ankao/internal/models"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
// JSONQuestion JSON中的题目结构
|
||||
type JSONQuestion struct {
|
||||
ID string `json:"id"`
|
||||
Question string `json:"question"`
|
||||
Answers interface{} `json:"answers"`
|
||||
Options interface{} `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
// JSONQuestionGroup JSON中的题目组结构
|
||||
type JSONQuestionGroup struct {
|
||||
Type string `json:"type"`
|
||||
TypeName string `json:"typeName"`
|
||||
List []JSONQuestion `json:"list"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
log.Println("开始导入题目数据...")
|
||||
|
||||
// 初始化数据库
|
||||
if err := database.InitDB(); err != nil {
|
||||
log.Fatal("数据库初始化失败:", err)
|
||||
}
|
||||
|
||||
// 读取JSON文件
|
||||
data, err := os.ReadFile("practice_question_pool.json")
|
||||
if err != nil {
|
||||
log.Fatal("读取JSON文件失败:", err)
|
||||
}
|
||||
|
||||
// 解析JSON
|
||||
var groups []JSONQuestionGroup
|
||||
if err := json.Unmarshal(data, &groups); err != nil {
|
||||
log.Fatal("解析JSON失败:", err)
|
||||
}
|
||||
|
||||
// 导入数据
|
||||
db := database.GetDB()
|
||||
totalCount := 0
|
||||
|
||||
for _, group := range groups {
|
||||
log.Printf("导入题型: %s (%s), 题目数量: %d", group.TypeName, group.Type, len(group.List))
|
||||
|
||||
for _, q := range group.List {
|
||||
// 将答案转换为JSON字符串存储
|
||||
answerJSON, err := json.Marshal(q.Answers)
|
||||
if err != nil {
|
||||
log.Printf("序列化答案失败 (ID: %s): %v", q.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// 将选项转换为JSON字符串存储
|
||||
optionsJSON := ""
|
||||
if q.Options != nil {
|
||||
optJSON, err := json.Marshal(q.Options)
|
||||
if err != nil {
|
||||
log.Printf("序列化选项失败 (ID: %s): %v", q.ID, err)
|
||||
continue
|
||||
}
|
||||
optionsJSON = string(optJSON)
|
||||
}
|
||||
|
||||
// 处理题型映射
|
||||
questionType := mapQuestionType(group.Type)
|
||||
|
||||
// 创建题目记录
|
||||
question := models.PracticeQuestion{
|
||||
QuestionID: q.ID,
|
||||
Type: questionType,
|
||||
TypeName: group.TypeName,
|
||||
Question: q.Question,
|
||||
AnswerData: string(answerJSON),
|
||||
OptionsData: optionsJSON,
|
||||
}
|
||||
|
||||
// 插入数据库
|
||||
if err := db.Create(&question).Error; err != nil {
|
||||
log.Printf("插入题目失败 (ID: %s): %v", q.ID, err)
|
||||
continue
|
||||
}
|
||||
totalCount++
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("数据导入完成! 共导入 %d 道题目", totalCount)
|
||||
}
|
||||
|
||||
// mapQuestionType 映射题型
|
||||
func mapQuestionType(jsonType string) models.PracticeQuestionType {
|
||||
switch jsonType {
|
||||
case "fill-in-blank":
|
||||
return models.FillInBlank
|
||||
case "true-false":
|
||||
return models.TrueFalseType
|
||||
case "multiple-choice":
|
||||
return models.MultipleChoiceQ
|
||||
case "multiple-selection":
|
||||
return models.MultipleSelection
|
||||
case "short-answer":
|
||||
return models.ShortAnswer
|
||||
default:
|
||||
return models.PracticeQuestionType(jsonType)
|
||||
}
|
||||
}
|
||||
@ -2,12 +2,10 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="description" content="AnKao - 安全保密考试系统" />
|
||||
<meta name="keywords" content="安全考试,保密考试,在线答题,考试系统" />
|
||||
<meta name="theme-color" content="#1890ff" />
|
||||
<title>AnKao - 安全保密考试</title>
|
||||
<meta name="description" content="AnKao 移动端应用" />
|
||||
<title>AnKao</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@ -10,14 +10,12 @@
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.1.0",
|
||||
"antd": "^5.28.0",
|
||||
"antd-mobile": "^5.37.1",
|
||||
"antd-mobile-icons": "^0.3.0",
|
||||
"axios": "^1.6.5",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.21.3",
|
||||
"remark-gfm": "^4.0.1"
|
||||
"react-router-dom": "^6.21.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.5",
|
||||
|
||||
@ -1,117 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="200" height="200">
|
||||
<defs>
|
||||
<linearGradient id="shieldGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#1890ff;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#096dd9;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="lockGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#52c41a;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#389e0d;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="3"/>
|
||||
<feOffset dx="0" dy="2" result="offsetblur"/>
|
||||
<feComponentTransfer>
|
||||
<feFuncA type="linear" slope="0.3"/>
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- 背景圆形 -->
|
||||
<circle cx="100" cy="100" r="95" fill="#f0f5ff" stroke="#d6e4ff" stroke-width="2"/>
|
||||
|
||||
<!-- 主盾牌形状 -->
|
||||
<path d="M 100 25
|
||||
C 80 25, 60 30, 45 40
|
||||
L 45 85
|
||||
C 45 120, 65 145, 100 165
|
||||
C 135 145, 155 120, 155 85
|
||||
L 155 40
|
||||
C 140 30, 120 25, 100 25 Z"
|
||||
fill="url(#shieldGradient)"
|
||||
stroke="#0050b3"
|
||||
stroke-width="2.5"
|
||||
filter="url(#shadow)"/>
|
||||
|
||||
<!-- 盾牌内部高光 -->
|
||||
<path d="M 100 30
|
||||
C 82 30, 65 34, 52 42
|
||||
L 52 85
|
||||
C 52 115, 70 138, 100 156"
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.4)"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"/>
|
||||
|
||||
<!-- 文档/试卷图标 -->
|
||||
<rect x="75" y="60" width="50" height="65" rx="3" ry="3"
|
||||
fill="#ffffff"
|
||||
stroke="#0050b3"
|
||||
stroke-width="2"/>
|
||||
|
||||
<!-- 试卷标题线 -->
|
||||
<line x1="82" y1="70" x2="118" y2="70"
|
||||
stroke="#1890ff"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"/>
|
||||
|
||||
<!-- 试卷内容线条 -->
|
||||
<line x1="82" y1="80" x2="112" y2="80"
|
||||
stroke="#8cc5ff"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"/>
|
||||
<line x1="82" y1="88" x2="115" y2="88"
|
||||
stroke="#8cc5ff"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"/>
|
||||
<line x1="82" y1="96" x2="108" y2="96"
|
||||
stroke="#8cc5ff"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"/>
|
||||
|
||||
<!-- 锁的主体 -->
|
||||
<rect x="88" y="105" width="24" height="15" rx="2" ry="2"
|
||||
fill="url(#lockGradient)"
|
||||
stroke="#237804"
|
||||
stroke-width="1.5"/>
|
||||
|
||||
<!-- 锁的U形环 -->
|
||||
<path d="M 93 105
|
||||
L 93 98
|
||||
A 7 7 0 0 1 107 98
|
||||
L 107 105"
|
||||
fill="none"
|
||||
stroke="url(#lockGradient)"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"/>
|
||||
|
||||
<!-- 锁孔 -->
|
||||
<circle cx="100" cy="110" r="2" fill="#ffffff"/>
|
||||
<rect x="99" y="110" width="2" height="4" rx="1" fill="#ffffff"/>
|
||||
|
||||
<!-- 装饰性星星(表示重要性) -->
|
||||
<path d="M 135 45 l 2 6 l 6 1 l -5 4 l 2 6 l -5 -3 l -5 3 l 2 -6 l -5 -4 l 6 -1 Z"
|
||||
fill="#faad14"
|
||||
stroke="#d48806"
|
||||
stroke-width="0.5"
|
||||
opacity="0.9"/>
|
||||
|
||||
<!-- 感叹号(警示标志) -->
|
||||
<g opacity="0.9">
|
||||
<rect x="140" y="130" width="3.5" height="15" rx="1.5" fill="#ff4d4f"/>
|
||||
<circle cx="141.75" cy="148" r="2" fill="#ff4d4f"/>
|
||||
</g>
|
||||
|
||||
<!-- 对勾标记(考试通过) -->
|
||||
<path d="M 60 135 L 65 142 L 75 128"
|
||||
fill="none"
|
||||
stroke="#52c41a"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
opacity="0.85"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.6 KiB |
@ -1,82 +1,29 @@
|
||||
import React from 'react'
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
|
||||
import { ConfigProvider } from 'antd'
|
||||
import zhCN from 'antd/locale/zh_CN'
|
||||
import TabBarLayout from './components/TabBarLayout'
|
||||
import ProtectedRoute from './components/ProtectedRoute'
|
||||
import AdminRoute from './components/AdminRoute'
|
||||
import QuestionPage from './pages/Question'
|
||||
import Profile from './pages/Profile'
|
||||
import Login from './pages/Login'
|
||||
import Home from './pages/Home'
|
||||
import About from './pages/About'
|
||||
import WrongQuestions from './pages/WrongQuestions'
|
||||
import QuestionManagement from './pages/QuestionManagement'
|
||||
import QuestionList from './pages/QuestionList'
|
||||
import UserManagement from './pages/UserManagement'
|
||||
import UserDetail from './pages/UserDetail'
|
||||
import ExamOnline from './pages/ExamOnline'
|
||||
import ExamPrint from './pages/ExamPrint'
|
||||
import ExamManagement from './pages/ExamManagement'
|
||||
import ExamTaking from './pages/ExamTaking'
|
||||
import ExamResultNew from './pages/ExamResultNew'
|
||||
import ExamAnswerView from './pages/ExamAnswerView'
|
||||
|
||||
const App: React.FC = () => {
|
||||
return (
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<Router>
|
||||
<Routes>
|
||||
{/* 带TabBar的页面,需要登录保护 */}
|
||||
<Route element={<ProtectedRoute><TabBarLayout /></ProtectedRoute>}>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/question" element={<QuestionPage />} />
|
||||
</Route>
|
||||
<Router>
|
||||
<Routes>
|
||||
{/* 带TabBar的页面,需要登录保护 */}
|
||||
<Route element={<ProtectedRoute><TabBarLayout /></ProtectedRoute>}>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/question" element={<QuestionPage />} />
|
||||
<Route path="/profile" element={<Profile />} />
|
||||
</Route>
|
||||
|
||||
{/* 不带TabBar的页面,但需要登录保护 */}
|
||||
<Route path="/wrong-questions" element={<ProtectedRoute><WrongQuestions /></ProtectedRoute>} />
|
||||
<Route path="/question-list" element={<ProtectedRoute><QuestionList /></ProtectedRoute>} />
|
||||
|
||||
{/* 考试相关页面,需要登录保护 */}
|
||||
<Route path="/exam/management" element={<ProtectedRoute><ExamManagement /></ProtectedRoute>} />
|
||||
<Route path="/exam/:examId/online" element={<ProtectedRoute><ExamOnline /></ProtectedRoute>} />
|
||||
<Route path="/exam/:examId/taking/:recordId" element={<ProtectedRoute><ExamTaking /></ProtectedRoute>} />
|
||||
<Route path="/exam/:examId/print" element={<ProtectedRoute><ExamPrint /></ProtectedRoute>} />
|
||||
<Route path="/exam/result/:recordId" element={<ProtectedRoute><ExamResultNew /></ProtectedRoute>} />
|
||||
<Route path="/exam/:examId/answer" element={<ProtectedRoute><ExamAnswerView /></ProtectedRoute>} />
|
||||
|
||||
{/* 题库管理页面,需要管理员权限 */}
|
||||
<Route path="/question-management" element={
|
||||
<ProtectedRoute>
|
||||
<AdminRoute>
|
||||
<QuestionManagement />
|
||||
</AdminRoute>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
{/* 用户管理页面,仅yanlongqi用户可访问 */}
|
||||
<Route path="/user-management" element={
|
||||
<ProtectedRoute>
|
||||
<AdminRoute>
|
||||
<UserManagement />
|
||||
</AdminRoute>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
{/* 用户详情页面,仅yanlongqi用户可访问 */}
|
||||
<Route path="/user-management/:id" element={
|
||||
<ProtectedRoute>
|
||||
<AdminRoute>
|
||||
<UserDetail />
|
||||
</AdminRoute>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
{/* 不带TabBar的页面,不需要登录保护 */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</ConfigProvider>
|
||||
{/* 不带TabBar的页面 */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,117 +0,0 @@
|
||||
import { request } from '../utils/request'
|
||||
import type {
|
||||
GenerateExamResponse,
|
||||
GetExamResponse,
|
||||
SubmitExamRequest,
|
||||
SubmitExamResponse,
|
||||
CreateExamRequest,
|
||||
CreateExamResponse,
|
||||
ExamListResponse,
|
||||
ExamDetailResponse,
|
||||
StartExamResponse,
|
||||
ExamRecordResponse,
|
||||
ExamRecordListResponse
|
||||
} from '../types/exam'
|
||||
import type { ApiResponse } from '../types/question'
|
||||
|
||||
// 创建试卷
|
||||
export const createExam = (data: CreateExamRequest) => {
|
||||
return request.post<ApiResponse<CreateExamResponse>>('/exams', data)
|
||||
}
|
||||
|
||||
// 获取试卷列表
|
||||
export const getExamList = () => {
|
||||
return request.get<ApiResponse<ExamListResponse>>('/exams')
|
||||
}
|
||||
|
||||
// 获取试卷详情
|
||||
export const getExamDetail = (examId: number) => {
|
||||
return request.get<ApiResponse<ExamDetailResponse>>(`/exams/${examId}`)
|
||||
}
|
||||
|
||||
// 开始考试
|
||||
export const startExam = (examId: number) => {
|
||||
return request.post<ApiResponse<StartExamResponse>>(`/exams/${examId}/start`)
|
||||
}
|
||||
|
||||
// 提交试卷答案
|
||||
export const submitExamAnswer = (recordId: number, data: SubmitExamRequest) => {
|
||||
return request.post<ApiResponse<SubmitExamResponse>>(`/exam-records/${recordId}/submit`, data)
|
||||
}
|
||||
|
||||
// 获取考试记录详情
|
||||
export const getExamRecord = (recordId: number) => {
|
||||
return request.get<ApiResponse<ExamRecordResponse>>(`/exam-records/${recordId}`)
|
||||
}
|
||||
|
||||
// 获取考试记录列表
|
||||
export const getExamRecordList = (examId?: number) => {
|
||||
return request.get<ApiResponse<ExamRecordListResponse>>('/exam-records', {
|
||||
params: examId ? { exam_id: examId } : undefined
|
||||
})
|
||||
}
|
||||
|
||||
// 删除试卷
|
||||
export const deleteExam = (examId: number) => {
|
||||
return request.delete<ApiResponse<void>>(`/exams/${examId}`)
|
||||
}
|
||||
|
||||
// 保存考试进度(单题答案)
|
||||
export const saveExamProgress = (recordId: number, data: { question_id: number; answer: any }) => {
|
||||
return request.post<ApiResponse<void>>(`/exam-records/${recordId}/progress`, data)
|
||||
}
|
||||
|
||||
// 获取用户答案
|
||||
export const getExamUserAnswers = (recordId: number) => {
|
||||
return request.get<ApiResponse<Record<string, any>>>(`/exam-records/${recordId}/answers`)
|
||||
}
|
||||
|
||||
// 获取可分享的用户列表
|
||||
export const getShareableUsers = () => {
|
||||
return request.get<ApiResponse<Array<{ id: number; username: string; nickname?: string; avatar?: string }>>>('/users/shareable')
|
||||
}
|
||||
|
||||
// 分享试卷
|
||||
export const shareExam = (examId: number, userIds: number[]) => {
|
||||
return request.post<ApiResponse<{ shared_count: number }>>(`/exams/${examId}/share`, { user_ids: userIds })
|
||||
}
|
||||
|
||||
// === 兼容旧版API ===
|
||||
|
||||
// 生成考试
|
||||
export const generateExam = () => {
|
||||
return request.post<ApiResponse<GenerateExamResponse>>('/exam/generate')
|
||||
}
|
||||
|
||||
// 获取考试详情
|
||||
export const getExam = (examId: number, showAnswer?: boolean) => {
|
||||
return request.get<ApiResponse<GetExamResponse>>(`/exams/${examId}`, {
|
||||
params: { show_answer: showAnswer },
|
||||
})
|
||||
}
|
||||
|
||||
// 提交考试
|
||||
export const submitExam = (examId: number, data: SubmitExamRequest) => {
|
||||
return request.post<ApiResponse<SubmitExamResponse>>(`/exam/${examId}/submit`, data)
|
||||
}
|
||||
|
||||
// 获取每日一练排行榜
|
||||
export const getDailyExamRanking = () => {
|
||||
return request.get<{
|
||||
success: boolean
|
||||
data: {
|
||||
exam_id: number
|
||||
exam_title: string
|
||||
rankings: Array<{
|
||||
user_id: number
|
||||
username: string
|
||||
nickname: string
|
||||
avatar: string
|
||||
score: number
|
||||
time_spent: number
|
||||
rank: number
|
||||
}>
|
||||
total: number
|
||||
}
|
||||
}>('/daily-exam/ranking')
|
||||
}
|
||||
@ -1,20 +1,16 @@
|
||||
import { request } from '../utils/request'
|
||||
import type {
|
||||
Question,
|
||||
SubmitAnswer,
|
||||
AnswerResult,
|
||||
Statistics,
|
||||
ApiResponse,
|
||||
WrongQuestion,
|
||||
WrongQuestionStats,
|
||||
WrongQuestionFilter
|
||||
} from '../types/question'
|
||||
import type { Question, SubmitAnswer, AnswerResult, Statistics, ApiResponse } from '../types/question'
|
||||
|
||||
// 获取题目列表
|
||||
export const getQuestions = (params?: { type?: string; search?: string }) => {
|
||||
export const getQuestions = (params?: { type?: string; category?: string }) => {
|
||||
return request.get<ApiResponse<Question[]>>('/practice/questions', { params })
|
||||
}
|
||||
|
||||
// 获取随机题目
|
||||
export const getRandomQuestion = () => {
|
||||
return request.get<ApiResponse<Question>>('/practice/questions/random')
|
||||
}
|
||||
|
||||
// 获取指定题目
|
||||
export const getQuestionById = (id: number) => {
|
||||
return request.get<ApiResponse<Question>>(`/practice/questions/${id}`)
|
||||
@ -25,44 +21,18 @@ export const submitAnswer = (data: SubmitAnswer) => {
|
||||
return request.post<ApiResponse<AnswerResult>>('/practice/submit', data)
|
||||
}
|
||||
|
||||
// 获取统计数据
|
||||
export const getStatistics = () => {
|
||||
return request.get<ApiResponse<Statistics>>('/practice/statistics')
|
||||
}
|
||||
|
||||
// ========== 练习进度相关 API ==========
|
||||
|
||||
// 答题记录
|
||||
export interface AnsweredQuestion {
|
||||
question_id: number
|
||||
record_id: number
|
||||
is_correct: boolean
|
||||
user_answer: any
|
||||
correct_answer: any // 正确答案
|
||||
answered_at: string
|
||||
// AI 评分相关(仅简答题有值)
|
||||
ai_score?: number
|
||||
ai_feedback?: string
|
||||
ai_suggestion?: string
|
||||
}
|
||||
|
||||
// 进度数据(按题目类型)
|
||||
export interface PracticeProgressData {
|
||||
type: string
|
||||
current_question_id: number
|
||||
answered_questions: AnsweredQuestion[]
|
||||
}
|
||||
|
||||
// 获取练习进度(可选type参数)
|
||||
export const getPracticeProgress = (type?: string) => {
|
||||
const params = type ? { type } : undefined
|
||||
return request.get<ApiResponse<PracticeProgressData[]>>('/practice/progress', { params })
|
||||
}
|
||||
|
||||
// 清除练习进度(可选type参数,指定类型则只清除该类型的进度)
|
||||
export const clearPracticeProgress = (type?: string) => {
|
||||
const params = type ? { type } : undefined
|
||||
return request.delete<ApiResponse<null>>('/practice/progress', { params })
|
||||
// 获取统计数据 (暂时返回模拟数据,后续实现)
|
||||
export const getStatistics = async () => {
|
||||
// TODO: 实现真实的统计接口
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
total_questions: 0,
|
||||
answered_questions: 0,
|
||||
correct_answers: 0,
|
||||
accuracy: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 重置进度 (暂时返回模拟数据,后续实现)
|
||||
@ -73,134 +43,3 @@ export const resetProgress = async () => {
|
||||
data: null
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 错题本相关 API ==========
|
||||
|
||||
// 获取错题列表(支持筛选和排序)
|
||||
export const getWrongQuestions = (filter?: WrongQuestionFilter) => {
|
||||
const params: Record<string, string> = {}
|
||||
if (filter?.is_mastered !== undefined) {
|
||||
params.is_mastered = filter.is_mastered ? 'true' : 'false'
|
||||
}
|
||||
if (filter?.tag) params.tag = filter.tag
|
||||
if (filter?.type) params.type = filter.type
|
||||
if (filter?.sort) params.sort = filter.sort
|
||||
|
||||
return request.get<ApiResponse<WrongQuestion[]>>('/wrong-questions', { params })
|
||||
}
|
||||
|
||||
// 获取错题详情(包含完整历史)
|
||||
export const getWrongQuestionDetail = (id: number) => {
|
||||
return request.get<ApiResponse<WrongQuestion>>(`/wrong-questions/${id}`)
|
||||
}
|
||||
|
||||
// 获取错题统计(含趋势数据)
|
||||
export const getWrongQuestionStats = () => {
|
||||
return request.get<ApiResponse<WrongQuestionStats>>('/wrong-questions/stats')
|
||||
}
|
||||
|
||||
// 获取推荐练习的错题(智能推荐)
|
||||
export const getRecommendedWrongQuestions = (limit: number = 10, excludeQuestionID?: number) => {
|
||||
const params: Record<string, any> = { limit }
|
||||
if (excludeQuestionID) {
|
||||
params.exclude = excludeQuestionID
|
||||
}
|
||||
return request.get<ApiResponse<WrongQuestion[]>>('/wrong-questions/recommended', {
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 删除错题
|
||||
export const deleteWrongQuestion = (id: number) => {
|
||||
return request.delete<ApiResponse<null>>(`/wrong-questions/${id}`)
|
||||
}
|
||||
|
||||
// 清空错题本
|
||||
export const clearWrongQuestions = () => {
|
||||
return request.delete<ApiResponse<null>>('/wrong-questions')
|
||||
}
|
||||
|
||||
// ========== 题库管理相关 API ==========
|
||||
|
||||
// 创建题目
|
||||
export const createQuestion = (data: {
|
||||
type: string
|
||||
type_name?: string
|
||||
question: string
|
||||
answer: any
|
||||
options?: Record<string, string>
|
||||
}) => {
|
||||
return request.post<ApiResponse<Question>>('/practice/questions', data)
|
||||
}
|
||||
|
||||
// 更新题目
|
||||
export const updateQuestion = (id: number, data: {
|
||||
type?: string
|
||||
type_name?: string
|
||||
question?: string
|
||||
answer?: any
|
||||
options?: Record<string, string>
|
||||
}) => {
|
||||
return request.put<ApiResponse<Question>>(`/practice/questions/${id}`, data)
|
||||
}
|
||||
|
||||
// 删除题目
|
||||
export const deleteQuestion = (id: number) => {
|
||||
return request.delete<ApiResponse<null>>(`/practice/questions/${id}`)
|
||||
}
|
||||
|
||||
// 获取题目解析(AI)
|
||||
export const explainQuestion = (questionId: number) => {
|
||||
return request.post<ApiResponse<{ explanation: string }>>('/practice/explain', { question_id: questionId })
|
||||
}
|
||||
|
||||
// ========== 用户管理相关 API(仅管理员) ==========
|
||||
|
||||
// 用户统计信息
|
||||
export interface UserStats {
|
||||
user_id: number
|
||||
username: string
|
||||
nickname: string
|
||||
avatar: string
|
||||
user_type: string
|
||||
total_answers: number
|
||||
correct_count: number
|
||||
wrong_count: number
|
||||
accuracy: number
|
||||
created_at: string
|
||||
last_answer_at?: string
|
||||
}
|
||||
|
||||
// 用户详细统计
|
||||
export interface UserDetailStats {
|
||||
user_info: UserStats
|
||||
type_stats: Array<{
|
||||
question_type: string
|
||||
question_type_name: string
|
||||
total_answers: number
|
||||
correct_count: number
|
||||
accuracy: number
|
||||
}>
|
||||
}
|
||||
|
||||
// 获取所有用户及统计(仅yanlongqi用户可访问)
|
||||
export const getAllUsersWithStats = () => {
|
||||
return request.get<ApiResponse<UserStats[]>>('/admin/users')
|
||||
}
|
||||
|
||||
// 获取用户详细统计(仅yanlongqi用户可访问)
|
||||
export const getUserDetailStats = (userId: number) => {
|
||||
return request.get<ApiResponse<UserDetailStats>>(`/admin/users/${userId}`)
|
||||
}
|
||||
|
||||
// ========== 排行榜相关 API ==========
|
||||
|
||||
// 获取今日排行榜
|
||||
export const getDailyRanking = (limit: number = 10) => {
|
||||
return request.get<ApiResponse<UserStats[]>>('/ranking/daily', { params: { limit } })
|
||||
}
|
||||
|
||||
// 获取总排行榜
|
||||
export const getTotalRanking = (limit: number = 10) => {
|
||||
return request.get<ApiResponse<UserStats[]>>('/ranking/total', { params: { limit } })
|
||||
}
|
||||
|
||||
@ -1,47 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { message } from 'antd'
|
||||
|
||||
interface AdminRouteProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const AdminRoute: React.FC<AdminRouteProps> = ({ children }) => {
|
||||
const [isAdmin, setIsAdmin] = useState<boolean | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// 检查用户信息
|
||||
const userStr = localStorage.getItem('user')
|
||||
if (!userStr) {
|
||||
setIsAdmin(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const user = JSON.parse(userStr)
|
||||
if (user.username === 'yanlongqi') {
|
||||
setIsAdmin(true)
|
||||
} else {
|
||||
setIsAdmin(false)
|
||||
message.error('无权限访问该页面')
|
||||
}
|
||||
} catch (e) {
|
||||
setIsAdmin(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 正在检查权限时,不显示任何内容
|
||||
if (isAdmin === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 如果不是管理员,重定向到首页
|
||||
if (!isAdmin) {
|
||||
return <Navigate to="/" replace />
|
||||
}
|
||||
|
||||
// 是管理员,显示子组件
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export default AdminRoute
|
||||
@ -1,530 +0,0 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Alert, Typography, Card, Space, Progress, Button, Spin } from 'antd'
|
||||
import { CheckOutlined, CloseOutlined, TrophyOutlined, CommentOutlined, BulbOutlined, FileTextOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { fetchWithAuth } from '../utils/request'
|
||||
import type { AnswerResult as AnswerResultType } from '../types/question'
|
||||
|
||||
const { Text, Paragraph } = Typography
|
||||
|
||||
interface AnswerResultProps {
|
||||
answerResult: AnswerResultType
|
||||
selectedAnswer: string | string[]
|
||||
questionType: string
|
||||
questionId: number
|
||||
}
|
||||
|
||||
const AnswerResult: React.FC<AnswerResultProps> = ({
|
||||
answerResult,
|
||||
selectedAnswer,
|
||||
questionType,
|
||||
questionId,
|
||||
}) => {
|
||||
const [explanation, setExplanation] = useState<string>('')
|
||||
const [showExplanation, setShowExplanation] = useState(false)
|
||||
const [loadingExplanation, setLoadingExplanation] = useState(false)
|
||||
|
||||
// 获取AI解析(流式)
|
||||
const fetchExplanation = async () => {
|
||||
console.log('开始获取AI解析(流式),题目ID:', questionId)
|
||||
setLoadingExplanation(true)
|
||||
setExplanation('') // 清空之前的内容
|
||||
|
||||
try {
|
||||
console.log('发送请求到 /api/practice/explain')
|
||||
const response = await fetchWithAuth('/api/practice/explain', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ question_id: questionId }),
|
||||
})
|
||||
|
||||
console.log('收到响应,状态码:', response.status)
|
||||
if (!response.ok) {
|
||||
throw new Error('请求失败')
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
if (!reader) {
|
||||
throw new Error('无法读取响应流')
|
||||
}
|
||||
|
||||
console.log('开始读取流式数据...')
|
||||
let chunkCount = 0
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
|
||||
if (done) {
|
||||
console.log('流读取完成,共接收', chunkCount, '个数据块')
|
||||
break
|
||||
}
|
||||
|
||||
chunkCount++
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6)
|
||||
if (data === '[DONE]') {
|
||||
console.log('收到完成信号 [DONE]')
|
||||
break
|
||||
}
|
||||
// 追加内容
|
||||
console.log('接收数据片段:', data.substring(0, 20) + '...')
|
||||
setExplanation(prev => prev + data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('AI解析获取成功')
|
||||
} catch (error) {
|
||||
console.error('获取解析失败', error)
|
||||
setExplanation('获取解析失败,请重试')
|
||||
} finally {
|
||||
setLoadingExplanation(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取AI解析
|
||||
const handleGetExplanation = async () => {
|
||||
if (explanation) {
|
||||
// 如果已经有解析,直接显示/隐藏
|
||||
setShowExplanation(!showExplanation)
|
||||
return
|
||||
}
|
||||
|
||||
setShowExplanation(true)
|
||||
await fetchExplanation()
|
||||
}
|
||||
|
||||
// 重新生成解析
|
||||
const handleRegenerateExplanation = async () => {
|
||||
console.log('点击重新生成解析按钮')
|
||||
await fetchExplanation()
|
||||
}
|
||||
|
||||
// 格式化答案显示(判断题特殊处理)
|
||||
const formatAnswer = (answer: string | string[] | boolean) => {
|
||||
// 处理判断题的布尔值和字符串
|
||||
if (questionType === 'true-false') {
|
||||
if (typeof answer === 'boolean') {
|
||||
return answer ? '正确' : '错误'
|
||||
}
|
||||
if (typeof answer === 'string') {
|
||||
return answer === 'true' ? '正确' : answer === 'false' ? '错误' : answer
|
||||
}
|
||||
}
|
||||
|
||||
// 处理数组答案
|
||||
if (Array.isArray(answer)) {
|
||||
// 填空题:保持原顺序,不排序(因为每个空格的位置是固定的)
|
||||
if (questionType === 'fill-in-blank') {
|
||||
return answer.join(', ')
|
||||
}
|
||||
// 多选题:按照ABCD顺序排序
|
||||
return [...answer].sort((a, b) => a.localeCompare(b)).join(', ')
|
||||
}
|
||||
|
||||
return String(answer)
|
||||
}
|
||||
|
||||
// 获取评分等级颜色
|
||||
const getScoreColor = (score: number) => {
|
||||
if (score >= 90) return '#52c41a' // 优秀 - 绿色
|
||||
if (score >= 80) return '#1890ff' // 良好 - 蓝色
|
||||
if (score >= 60) return '#faad14' // 及格 - 橙色
|
||||
return '#ff4d4f' // 不及格 - 红色
|
||||
}
|
||||
|
||||
// 获取评分等级
|
||||
const getScoreLevel = (score: number) => {
|
||||
if (score >= 90) return '优秀'
|
||||
if (score >= 80) return '良好'
|
||||
if (score >= 60) return '及格'
|
||||
return '不及格'
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 20 }}>
|
||||
{/* 答题结果 */}
|
||||
<Alert
|
||||
type={answerResult.correct ? 'success' : 'error'}
|
||||
icon={answerResult.correct ? <CheckOutlined /> : <CloseOutlined />}
|
||||
message={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<strong>{answerResult.correct ? '回答正确!' : '回答错误'}</strong>
|
||||
{/* AI解析按钮 - 放在答题结果的右上角 */}
|
||||
<Button
|
||||
type="link"
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={handleGetExplanation}
|
||||
loading={loadingExplanation}
|
||||
size="small"
|
||||
>
|
||||
{showExplanation ? '隐藏解析' : 'AI解析'}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
<div>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Text type="secondary">你的答案:</Text>
|
||||
<Text strong={answerResult.correct} type={answerResult.correct ? undefined : 'danger'}>
|
||||
{formatAnswer(answerResult.user_answer || selectedAnswer)}
|
||||
</Text>
|
||||
</div>
|
||||
{/* 论述题不显示正确答案,因为没有标准答案 */}
|
||||
{questionType !== 'ordinary-essay' && questionType !== 'management-essay' && (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Text strong style={{ color: '#52c41a' }}>
|
||||
正确答案:
|
||||
</Text>
|
||||
<Text strong style={{ color: '#52c41a' }}>
|
||||
{formatAnswer(
|
||||
answerResult.correct_answer !== undefined && answerResult.correct_answer !== null
|
||||
? answerResult.correct_answer
|
||||
: (answerResult.correct ? selectedAnswer : '暂无')
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{answerResult.explanation && (
|
||||
<div>
|
||||
<Text type="secondary">解析:</Text>
|
||||
<div style={{ marginTop: 4 }}>{answerResult.explanation}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* AI解析内容 */}
|
||||
{showExplanation && (
|
||||
<Card
|
||||
style={{ marginTop: 16 }}
|
||||
title={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Space>
|
||||
<FileTextOutlined style={{ color: '#1890ff' }} />
|
||||
<Text strong>题目解析</Text>
|
||||
</Space>
|
||||
{explanation && !loadingExplanation && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleRegenerateExplanation}
|
||||
size="small"
|
||||
style={{ color: '#1890ff' }}
|
||||
>
|
||||
重新生成
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ color: '#595959', lineHeight: '1.8' }}>
|
||||
{explanation ? (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
// 自定义markdown组件样式
|
||||
p: ({ children }) => (
|
||||
<p style={{
|
||||
marginBottom: '1em',
|
||||
lineHeight: '1.8',
|
||||
wordWrap: 'break-word',
|
||||
whiteSpace: 'pre-wrap'
|
||||
}}>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
h1: ({ children }) => (
|
||||
<h1 style={{
|
||||
fontSize: '1.75em',
|
||||
fontWeight: 'bold',
|
||||
marginTop: '1em',
|
||||
marginBottom: '0.6em',
|
||||
borderBottom: '2px solid #e8e8e8',
|
||||
paddingBottom: '0.3em'
|
||||
}}>
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 style={{
|
||||
fontSize: '1.5em',
|
||||
fontWeight: 'bold',
|
||||
marginTop: '1em',
|
||||
marginBottom: '0.5em',
|
||||
color: '#262626'
|
||||
}}>
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 style={{
|
||||
fontSize: '1.25em',
|
||||
fontWeight: 'bold',
|
||||
marginTop: '0.8em',
|
||||
marginBottom: '0.5em',
|
||||
color: '#262626'
|
||||
}}>
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul style={{
|
||||
marginLeft: '1.5em',
|
||||
marginBottom: '1em',
|
||||
paddingLeft: '0.5em',
|
||||
lineHeight: '1.8'
|
||||
}}>
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol style={{
|
||||
marginLeft: '1.5em',
|
||||
marginBottom: '1em',
|
||||
paddingLeft: '0.5em',
|
||||
lineHeight: '1.8'
|
||||
}}>
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }) => (
|
||||
<li style={{
|
||||
marginBottom: '0.4em',
|
||||
lineHeight: '1.8'
|
||||
}}>
|
||||
{children}
|
||||
</li>
|
||||
),
|
||||
code: ({ children, className }) => {
|
||||
const isInline = !className
|
||||
return isInline ? (
|
||||
<code style={{
|
||||
backgroundColor: '#f5f5f5',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '3px',
|
||||
fontSize: '0.9em',
|
||||
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
|
||||
color: '#c7254e'
|
||||
}}>
|
||||
{children}
|
||||
</code>
|
||||
) : (
|
||||
<code className={className} style={{
|
||||
display: 'block',
|
||||
fontFamily: 'Consolas, Monaco, "Courier New", monospace'
|
||||
}}>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
},
|
||||
pre: ({ children }) => (
|
||||
<pre style={{
|
||||
backgroundColor: '#f5f5f5',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '4px',
|
||||
overflow: 'auto',
|
||||
marginBottom: '1em',
|
||||
border: '1px solid #e8e8e8'
|
||||
}}>
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote style={{
|
||||
borderLeft: '4px solid #1890ff',
|
||||
paddingLeft: '16px',
|
||||
margin: '1em 0',
|
||||
color: '#666',
|
||||
fontStyle: 'italic',
|
||||
backgroundColor: '#f0f9ff',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '0 4px 4px 0'
|
||||
}}>
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div style={{ overflowX: 'auto', marginBottom: '1em' }}>
|
||||
<table style={{
|
||||
borderCollapse: 'collapse',
|
||||
width: '100%',
|
||||
border: '1px solid #e8e8e8'
|
||||
}}>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th style={{
|
||||
border: '1px solid #e8e8e8',
|
||||
padding: '8px 12px',
|
||||
backgroundColor: '#fafafa',
|
||||
textAlign: 'left',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td style={{
|
||||
border: '1px solid #e8e8e8',
|
||||
padding: '8px 12px'
|
||||
}}>
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
strong: ({ children }) => (
|
||||
<strong style={{ fontWeight: 'bold', color: '#262626' }}>{children}</strong>
|
||||
),
|
||||
em: ({ children }) => (
|
||||
<em style={{ fontStyle: 'italic', color: '#595959' }}>{children}</em>
|
||||
),
|
||||
hr: () => (
|
||||
<hr style={{
|
||||
border: 'none',
|
||||
borderTop: '1px solid #e8e8e8',
|
||||
margin: '1.5em 0'
|
||||
}} />
|
||||
),
|
||||
}}
|
||||
>
|
||||
{explanation}
|
||||
</ReactMarkdown>
|
||||
) : (
|
||||
loadingExplanation ? (
|
||||
<div style={{ textAlign: 'center', padding: '20px 0' }}>
|
||||
<Spin tip="AI正在生成解析中..." />
|
||||
</div>
|
||||
) : (
|
||||
<Paragraph style={{ marginBottom: 0 }}>暂无解析内容</Paragraph>
|
||||
)
|
||||
)}
|
||||
{/* 流式输出时的loading提示 */}
|
||||
{loadingExplanation && explanation && (
|
||||
<div style={{ marginTop: '10px', color: '#1890ff', fontSize: '14px' }}>
|
||||
<Spin size="small" style={{ marginRight: '8px' }} />
|
||||
正在生成中...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* AI评分结果 - 简答题和论述题显示 */}
|
||||
{answerResult.ai_grading && (
|
||||
<Card
|
||||
style={{
|
||||
marginTop: 16,
|
||||
borderColor: getScoreColor(answerResult.ai_grading.score),
|
||||
borderWidth: 2,
|
||||
}}
|
||||
title={
|
||||
<Space>
|
||||
<TrophyOutlined style={{ color: getScoreColor(answerResult.ai_grading.score) }} />
|
||||
<Text strong>AI智能评分</Text>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{/* 分数和进度条 */}
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<Space align="center" size="large">
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 14 }}>得分</Text>
|
||||
<div>
|
||||
<Text
|
||||
strong
|
||||
style={{
|
||||
fontSize: 32,
|
||||
color: getScoreColor(answerResult.ai_grading.score),
|
||||
}}
|
||||
>
|
||||
{answerResult.ai_grading.score}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 18 }}> / 100</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 200 }}>
|
||||
<Progress
|
||||
percent={answerResult.ai_grading.score}
|
||||
strokeColor={getScoreColor(answerResult.ai_grading.score)}
|
||||
format={(percent) => `${getScoreLevel(percent || 0)}`}
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 参考答案(论述题) */}
|
||||
{answerResult.ai_grading.reference_answer && (
|
||||
<div style={{ marginBottom: 16, padding: 12, backgroundColor: '#f0f9ff', borderRadius: 4 }}>
|
||||
<Space align="start">
|
||||
<FileTextOutlined style={{ fontSize: 16, color: '#1890ff', marginTop: 2 }} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text strong style={{ fontSize: 14, color: '#1890ff' }}>参考答案:</Text>
|
||||
<Paragraph style={{ marginTop: 4, marginBottom: 0, color: '#262626', whiteSpace: 'pre-wrap' }}>
|
||||
{answerResult.ai_grading.reference_answer}
|
||||
</Paragraph>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 评分依据 */}
|
||||
{answerResult.ai_grading.scoring_rationale && (
|
||||
<div style={{ marginBottom: 16, padding: 12, backgroundColor: '#f6ffed', borderRadius: 4 }}>
|
||||
<Space align="start">
|
||||
<CheckOutlined style={{ fontSize: 16, color: '#52c41a', marginTop: 2 }} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text strong style={{ fontSize: 14, color: '#52c41a' }}>评分依据:</Text>
|
||||
<Paragraph style={{ marginTop: 4, marginBottom: 0, color: '#262626', whiteSpace: 'pre-wrap' }}>
|
||||
{answerResult.ai_grading.scoring_rationale}
|
||||
</Paragraph>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 评语 */}
|
||||
{answerResult.ai_grading.feedback && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Space align="start">
|
||||
<CommentOutlined style={{ fontSize: 16, color: '#1890ff', marginTop: 2 }} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text strong style={{ fontSize: 14 }}>评语:</Text>
|
||||
<Paragraph style={{ marginTop: 4, marginBottom: 0, color: '#595959' }}>
|
||||
{answerResult.ai_grading.feedback}
|
||||
</Paragraph>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 改进建议 */}
|
||||
{answerResult.ai_grading.suggestion && (
|
||||
<div>
|
||||
<Space align="start">
|
||||
<BulbOutlined style={{ fontSize: 16, color: '#faad14', marginTop: 2 }} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text strong style={{ fontSize: 14 }}>改进建议:</Text>
|
||||
<Paragraph style={{ marginTop: 4, marginBottom: 0, color: '#595959' }}>
|
||||
{answerResult.ai_grading.suggestion}
|
||||
</Paragraph>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AnswerResult
|
||||
@ -1,105 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Modal, Button, Space, Typography } from 'antd'
|
||||
import { TrophyOutlined } from '@ant-design/icons'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
interface CompletionSummaryProps {
|
||||
visible: boolean
|
||||
totalQuestions: number
|
||||
correctCount: number
|
||||
wrongCount: number
|
||||
category?: string
|
||||
onClose: () => void
|
||||
onRetry: () => void
|
||||
}
|
||||
|
||||
const CompletionSummary: React.FC<CompletionSummaryProps> = ({
|
||||
visible,
|
||||
totalQuestions,
|
||||
correctCount,
|
||||
wrongCount,
|
||||
category,
|
||||
onClose,
|
||||
onRetry,
|
||||
}) => {
|
||||
const accuracy = totalQuestions > 0 ? Math.round((correctCount / totalQuestions) * 100) : 0
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<TrophyOutlined style={{ fontSize: 48, color: '#faad14', marginBottom: 16 }} />
|
||||
<Title level={3} style={{ margin: 0 }}>
|
||||
完成统计
|
||||
</Title>
|
||||
</div>
|
||||
}
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
footer={[
|
||||
<Button key="home" type="primary" onClick={onClose}>
|
||||
返回首页
|
||||
</Button>,
|
||||
<Button key="retry" onClick={onRetry}>
|
||||
重新开始
|
||||
</Button>,
|
||||
]}
|
||||
width={500}
|
||||
>
|
||||
<div style={{ textAlign: 'center', padding: '20px 0' }}>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Title level={4} style={{ marginBottom: 16 }}>
|
||||
本次答题已完成!
|
||||
</Title>
|
||||
<Text type="secondary">题目类型:{category || '全部题型'}</Text>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-around', padding: '20px 0' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 36, fontWeight: 'bold', color: '#1890ff' }}>
|
||||
{totalQuestions}
|
||||
</div>
|
||||
<Text type="secondary">总题数</Text>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 36, fontWeight: 'bold', color: '#52c41a' }}>
|
||||
{correctCount}
|
||||
</div>
|
||||
<Text type="secondary">正确数</Text>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 36, fontWeight: 'bold', color: '#ff4d4f' }}>
|
||||
{wrongCount}
|
||||
</div>
|
||||
<Text type="secondary">错误数</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: 20,
|
||||
background: '#f0f2f5',
|
||||
borderRadius: 8,
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
<Text>正确率:</Text>
|
||||
<Text
|
||||
style={{
|
||||
color: accuracy >= 60 ? '#52c41a' : '#ff4d4f',
|
||||
fontSize: 32,
|
||||
}}
|
||||
>
|
||||
{accuracy}%
|
||||
</Text>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default CompletionSummary
|
||||
@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import { Button, Space } from 'antd'
|
||||
import { Button, Space } from 'antd-mobile'
|
||||
import styles from './DemoButton.module.less'
|
||||
|
||||
interface DemoButtonProps {
|
||||
@ -13,17 +13,17 @@ const DemoButton: React.FC<DemoButtonProps> = ({
|
||||
}) => {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Button type="primary" block onClick={onClick}>
|
||||
<Space direction="vertical" block>
|
||||
<Button color="primary" block onClick={onClick}>
|
||||
{text} - Primary
|
||||
</Button>
|
||||
<Button type="default" block onClick={onClick}>
|
||||
{text} - Default
|
||||
<Button color="success" block onClick={onClick}>
|
||||
{text} - Success
|
||||
</Button>
|
||||
<Button type="dashed" block onClick={onClick}>
|
||||
{text} - Dashed
|
||||
<Button color="warning" block onClick={onClick}>
|
||||
{text} - Warning
|
||||
</Button>
|
||||
<Button type="primary" danger block onClick={onClick}>
|
||||
<Button color="danger" block onClick={onClick}>
|
||||
{text} - Danger
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
@ -1,231 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Space, Tag, Typography, Radio, Checkbox, Input, Button } from 'antd'
|
||||
import { ReloadOutlined } from '@ant-design/icons'
|
||||
import type { Question, AnswerResult as AnswerResultType } from '../types/question'
|
||||
import AnswerResult from './AnswerResult'
|
||||
import styles from '../pages/Question.module.less'
|
||||
|
||||
const { TextArea } = Input
|
||||
const { Title } = Typography
|
||||
|
||||
interface QuestionCardProps {
|
||||
question: Question
|
||||
selectedAnswer: string | string[]
|
||||
showResult: boolean
|
||||
answerResult: AnswerResultType | null
|
||||
loading: boolean
|
||||
autoNextLoading: boolean
|
||||
onAnswerChange: (answer: string | string[]) => void
|
||||
onSubmit: () => void
|
||||
onNext: () => void
|
||||
onRetry?: () => void // 新增:重新答题回调
|
||||
mode?: string // 新增:答题模式(用于判断是否是错题练习)
|
||||
answerSequence?: number // 新增:答题序号(第几次答题)
|
||||
hasHistory?: boolean // 新增:是否有历史答案
|
||||
}
|
||||
|
||||
const QuestionCard: React.FC<QuestionCardProps> = ({
|
||||
question,
|
||||
selectedAnswer,
|
||||
showResult,
|
||||
answerResult,
|
||||
loading,
|
||||
autoNextLoading,
|
||||
onAnswerChange,
|
||||
onSubmit,
|
||||
onNext,
|
||||
onRetry,
|
||||
mode,
|
||||
answerSequence,
|
||||
hasHistory,
|
||||
}) => {
|
||||
const [fillAnswers, setFillAnswers] = useState<string[]>([])
|
||||
|
||||
// 当题目或答案变化时,同步填空题答案
|
||||
useEffect(() => {
|
||||
if (question.type === 'fill-in-blank') {
|
||||
// 如果 selectedAnswer 是数组且有内容,使用它;否则重置为空数组
|
||||
if (Array.isArray(selectedAnswer) && selectedAnswer.length > 0) {
|
||||
setFillAnswers(selectedAnswer)
|
||||
} else {
|
||||
setFillAnswers([])
|
||||
}
|
||||
}
|
||||
}, [question.id, question.type, selectedAnswer])
|
||||
|
||||
// 渲染填空题内容
|
||||
const renderFillContent = () => {
|
||||
const content = question.content
|
||||
const parts = content.split('****')
|
||||
|
||||
if (parts.length === 1) {
|
||||
return <div className={styles.questionContent}>{content}</div>
|
||||
}
|
||||
|
||||
if (fillAnswers.length === 0) {
|
||||
setFillAnswers(new Array(parts.length - 1).fill(''))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.questionContent}>
|
||||
{parts.map((part, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<span>{part}</span>
|
||||
{index < parts.length - 1 && (
|
||||
<Input
|
||||
className={styles.fillInput}
|
||||
placeholder={`填空${index + 1}`}
|
||||
value={fillAnswers[index] || ''}
|
||||
onChange={(e) => {
|
||||
const newAnswers = [...fillAnswers]
|
||||
newAnswers[index] = e.target.value
|
||||
setFillAnswers(newAnswers)
|
||||
// 提交时去掉每个答案的前后空白字符
|
||||
const trimmedAnswers = newAnswers.map(ans => ans.trim())
|
||||
onAnswerChange(trimmedAnswers)
|
||||
}}
|
||||
disabled={showResult}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: '120px',
|
||||
margin: '0 8px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 渲染题目选项
|
||||
const renderOptions = () => {
|
||||
if (question.type === 'fill-in-blank') {
|
||||
return null
|
||||
}
|
||||
|
||||
// 简答题和论述题都显示文本框
|
||||
if (question.type === 'short-answer' || question.type === 'ordinary-essay' || question.type === 'management-essay') {
|
||||
return (
|
||||
<TextArea
|
||||
placeholder="请输入答案"
|
||||
value={selectedAnswer as string}
|
||||
onChange={(e) => onAnswerChange(e.target.value)}
|
||||
disabled={showResult}
|
||||
rows={6}
|
||||
style={{ marginTop: 20 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (question.type === 'multiple-selection') {
|
||||
const sortedOptions = [...question.options].sort((a, b) => a.key.localeCompare(b.key))
|
||||
|
||||
return (
|
||||
<Checkbox.Group
|
||||
value={selectedAnswer as string[]}
|
||||
onChange={(val) => onAnswerChange(val as string[])}
|
||||
disabled={showResult}
|
||||
style={{ width: '100%', marginTop: 20 }}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{sortedOptions.map((option) => (
|
||||
<Checkbox key={option.key} value={option.key}>
|
||||
<span style={{ fontSize: 16 }}>
|
||||
{option.key}. {option.value}
|
||||
</span>
|
||||
</Checkbox>
|
||||
))}
|
||||
</Space>
|
||||
</Checkbox.Group>
|
||||
)
|
||||
}
|
||||
|
||||
// 单选题和判断题
|
||||
// 判断题不排序,保持后端返回的顺序(正确在前,错误在后)
|
||||
const sortedOptions = question.type === 'true-false'
|
||||
? question.options
|
||||
: [...question.options].sort((a, b) => a.key.localeCompare(b.key))
|
||||
|
||||
return (
|
||||
<Radio.Group
|
||||
value={selectedAnswer as string}
|
||||
onChange={(e) => onAnswerChange(e.target.value)}
|
||||
disabled={showResult}
|
||||
style={{ width: '100%', marginTop: 20 }}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{sortedOptions.map((option) => (
|
||||
<Radio key={option.key} value={option.key}>
|
||||
<span style={{ fontSize: 16 }}>
|
||||
{question.type === 'true-false' ? option.value : `${option.key}. ${option.value}`}
|
||||
</span>
|
||||
</Radio>
|
||||
))}
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.questionCard}>
|
||||
<Space size="small" style={{ marginBottom: 16, alignItems: 'center' }}>
|
||||
<Title level={5} style={{ margin: 0, display: 'inline' }}>
|
||||
第 {question.question_id} 题
|
||||
</Title>
|
||||
<Tag color="blue">{question.category}</Tag>
|
||||
{/* 显示答题历史提示 */}
|
||||
{hasHistory && answerSequence && answerSequence > 1 && (
|
||||
<Tag color="orange">第 {answerSequence} 次答题</Tag>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
{question.type === 'fill-in-blank' ? (
|
||||
renderFillContent()
|
||||
) : (
|
||||
<div className={styles.questionContent}>{question.content}</div>
|
||||
)}
|
||||
|
||||
{renderOptions()}
|
||||
|
||||
{/* 答案结果 */}
|
||||
{showResult && answerResult && (
|
||||
<AnswerResult
|
||||
answerResult={answerResult}
|
||||
selectedAnswer={selectedAnswer}
|
||||
questionType={question.type}
|
||||
questionId={question.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 按钮 */}
|
||||
<div className={styles.buttonGroup}>
|
||||
{!showResult ? (
|
||||
<Button type="primary" size="large" block onClick={onSubmit} loading={loading}>
|
||||
提交答案
|
||||
</Button>
|
||||
) : (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
{/* 如果是错题练习模式且答案错误,显示重新答题按钮 */}
|
||||
{mode === 'wrong' && !answerResult?.correct && onRetry && (
|
||||
<Button
|
||||
type="default"
|
||||
size="large"
|
||||
block
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={onRetry}
|
||||
>
|
||||
重新答题
|
||||
</Button>
|
||||
)}
|
||||
<Button type="primary" size="large" block onClick={onNext} loading={autoNextLoading}>
|
||||
{autoNextLoading ? '正在跳转到下一题...' : '下一题'}
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default QuestionCard
|
||||
@ -1,136 +0,0 @@
|
||||
.drawer {
|
||||
:global {
|
||||
.ant-drawer-body {
|
||||
padding: 0;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.ant-drawer-header {
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
background: #fff;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.ant-drawer-title {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.listItem {
|
||||
padding: 0;
|
||||
margin: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
overflow: visible;
|
||||
border: 1px solid transparent;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(22, 119, 255, 0.2);
|
||||
}
|
||||
|
||||
&.current {
|
||||
background: linear-gradient(135deg, #e6f7ff 0%, #f0f9ff 100%);
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 4px 16px rgba(22, 119, 255, 0.15);
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 6px 20px rgba(22, 119, 255, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.questionItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.questionNumber {
|
||||
flex-shrink: 0;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #595959;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.current .questionNumber {
|
||||
color: #1677ff;
|
||||
background: rgba(22, 119, 255, 0.1);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.questionContent {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.questionStatus {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
:global {
|
||||
.anticon {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
.drawer {
|
||||
:global {
|
||||
.ant-drawer {
|
||||
width: 90vw !important;
|
||||
max-width: 450px;
|
||||
}
|
||||
|
||||
.ant-drawer-header {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.listItem {
|
||||
margin: 6px 10px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.questionItem {
|
||||
padding: 8px 10px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.questionNumber {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 12px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.questionStatus {
|
||||
:global {
|
||||
.anticon {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,138 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Drawer, List, Tag, Typography, Space } from 'antd'
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
MinusCircleOutlined,
|
||||
BookOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import type { Question } from '../types/question'
|
||||
import styles from './QuestionDrawer.module.less'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
interface QuestionDrawerProps {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
questions: Question[]
|
||||
currentIndex: number
|
||||
onQuestionSelect: (index: number) => void
|
||||
answeredStatus: Map<number, boolean | null> // null: 未答, true: 正确, false: 错误
|
||||
}
|
||||
|
||||
const QuestionDrawer: React.FC<QuestionDrawerProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
questions,
|
||||
currentIndex,
|
||||
onQuestionSelect,
|
||||
answeredStatus,
|
||||
}) => {
|
||||
// 获取题目状态
|
||||
const getQuestionStatus = (index: number) => {
|
||||
const status = answeredStatus.get(index)
|
||||
if (status === null || status === undefined) {
|
||||
return { icon: <MinusCircleOutlined />, color: '#d9d9d9', text: '未答' }
|
||||
}
|
||||
if (status) {
|
||||
return { icon: <CheckCircleOutlined />, color: '#52c41a', text: '正确' }
|
||||
}
|
||||
return { icon: <CloseCircleOutlined />, color: '#ff4d4f', text: '错误' }
|
||||
}
|
||||
|
||||
// 渲染填空题内容:将 **** 替换为下划线
|
||||
const renderQuestionContent = (question: Question) => {
|
||||
if (question.type === 'fill-in-blank') {
|
||||
const parts = question.content.split('****')
|
||||
return (
|
||||
<span>
|
||||
{parts.map((part, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{part}
|
||||
{index < parts.length - 1 && (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
minWidth: '40px',
|
||||
borderBottom: '2px solid #d9d9d9',
|
||||
margin: '0 4px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
|
||||
</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return question.content
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={
|
||||
<Space>
|
||||
<BookOutlined />
|
||||
<span>题目导航</span>
|
||||
<Text type="secondary" style={{ fontSize: 14, fontWeight: 'normal' }}>
|
||||
共 {questions.length} 题
|
||||
</Text>
|
||||
</Space>
|
||||
}
|
||||
placement="right"
|
||||
onClose={onClose}
|
||||
open={visible}
|
||||
width={450}
|
||||
className={styles.drawer}
|
||||
>
|
||||
<List
|
||||
dataSource={questions}
|
||||
renderItem={(question, index) => {
|
||||
const status = getQuestionStatus(index)
|
||||
const isCurrent = index === currentIndex
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
key={question.id}
|
||||
className={`${styles.listItem} ${isCurrent ? styles.current : ''}`}
|
||||
onClick={() => {
|
||||
onQuestionSelect(index)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<div className={styles.questionItem}>
|
||||
{/* 题号 */}
|
||||
<div className={styles.questionNumber}>
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
{/* 分类标签 */}
|
||||
<Tag color="blue" style={{ margin: 0, flexShrink: 0 }}>
|
||||
{question.category}
|
||||
</Tag>
|
||||
|
||||
{/* 题目内容 */}
|
||||
<div className={styles.questionContent}>
|
||||
<Text ellipsis={{ tooltip: question.content }} style={{ fontSize: 14, color: '#262626' }}>
|
||||
{renderQuestionContent(question)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* 右侧状态 */}
|
||||
<div className={styles.questionStatus}>
|
||||
<div style={{ color: status.color, fontSize: 20 }}>
|
||||
{status.icon}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</List.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
export default QuestionDrawer
|
||||
@ -1,215 +0,0 @@
|
||||
.floatButtonWrapper {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: 90px;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.statsCard {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-radius: 12px;
|
||||
padding: 8px 16px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08),
|
||||
0 2px 6px rgba(0, 0, 0, 0.04);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
animation: slideIn 0.3s ease forwards 0.2s;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.statsRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.statsLabel {
|
||||
font-size: 11px;
|
||||
color: #8c8c8c;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.statsValue {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #1d1d1f;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.statsDivider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.floatButton {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4),
|
||||
0 8px 32px rgba(102, 126, 234, 0.25);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
user-select: none;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1) translateY(-4px);
|
||||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5),
|
||||
0 12px 40px rgba(102, 126, 234, 0.35);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(1.05) translateY(-2px);
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.progressRing {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: rotate(-90deg);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.progressRingCircle {
|
||||
opacity: 0.15;
|
||||
stroke: #fff;
|
||||
}
|
||||
|
||||
.progressRingCircleProgress {
|
||||
transition: stroke-dashoffset 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
stroke-linecap: round;
|
||||
stroke: #fff;
|
||||
filter: drop-shadow(0 0 4px rgba(255, 255, 255, 0.5));
|
||||
}
|
||||
|
||||
.floatButtonContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 24px;
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
.floatButtonWrapper {
|
||||
right: 16px;
|
||||
bottom: 80px;
|
||||
}
|
||||
|
||||
.statsCard {
|
||||
padding: 6px 12px;
|
||||
gap: 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.statsLabel {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.statsValue {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.statsDivider {
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.floatButton {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
// 超小屏幕优化
|
||||
@media (max-width: 380px) {
|
||||
.floatButtonWrapper {
|
||||
right: 12px;
|
||||
bottom: 75px;
|
||||
}
|
||||
|
||||
.statsCard {
|
||||
padding: 5px 10px;
|
||||
gap: 6px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.statsLabel {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.statsValue {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.statsDivider {
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.floatButton {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加微妙的脉冲动画
|
||||
@keyframes subtlePulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4),
|
||||
0 8px 32px rgba(102, 126, 234, 0.25);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.5),
|
||||
0 8px 32px rgba(102, 126, 234, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
.floatButton {
|
||||
animation: subtlePulse 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@ -1,89 +0,0 @@
|
||||
import React from 'react'
|
||||
import { UnorderedListOutlined } from '@ant-design/icons'
|
||||
import styles from './QuestionFloatButton.module.less'
|
||||
|
||||
interface QuestionFloatButtonProps {
|
||||
currentIndex: number
|
||||
totalQuestions: number
|
||||
onClick: () => void
|
||||
correctCount: number
|
||||
wrongCount: number
|
||||
}
|
||||
|
||||
const QuestionFloatButton: React.FC<QuestionFloatButtonProps> = ({
|
||||
currentIndex: _currentIndex, // 保留参数但表示未使用
|
||||
totalQuestions,
|
||||
onClick,
|
||||
correctCount,
|
||||
wrongCount,
|
||||
}) => {
|
||||
if (totalQuestions === 0) return null
|
||||
|
||||
const answeredCount = correctCount + wrongCount
|
||||
const progress = Math.round((answeredCount / totalQuestions) * 100)
|
||||
|
||||
const radius = 28.5
|
||||
const circumference = 2 * Math.PI * radius
|
||||
|
||||
return (
|
||||
<div className={styles.floatButtonWrapper}>
|
||||
{/* 统计信息卡片 */}
|
||||
<div className={styles.statsCard}>
|
||||
<div className={styles.statsRow}>
|
||||
<span className={styles.statsLabel}>进度</span>
|
||||
<span className={styles.statsValue}>
|
||||
{answeredCount}/{totalQuestions}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.statsDivider} />
|
||||
<div className={styles.statsRow}>
|
||||
<span className={styles.statsLabel}>正确</span>
|
||||
<span className={styles.statsValue} style={{ color: '#52c41a' }}>
|
||||
{correctCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.statsDivider} />
|
||||
<div className={styles.statsRow}>
|
||||
<span className={styles.statsLabel}>错误</span>
|
||||
<span className={styles.statsValue} style={{ color: '#ff4d4f' }}>
|
||||
{wrongCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 悬浮球 */}
|
||||
<div className={styles.floatButton} onClick={onClick}>
|
||||
{/* 进度环 */}
|
||||
<svg className={styles.progressRing} viewBox="0 0 64 64">
|
||||
<circle
|
||||
className={styles.progressRingCircle}
|
||||
strokeWidth="3"
|
||||
fill="transparent"
|
||||
r={radius}
|
||||
cx="32"
|
||||
cy="32"
|
||||
/>
|
||||
<circle
|
||||
className={styles.progressRingCircleProgress}
|
||||
strokeWidth="3"
|
||||
fill="transparent"
|
||||
r={radius}
|
||||
cx="32"
|
||||
cy="32"
|
||||
style={{
|
||||
strokeDasharray: `${circumference}`,
|
||||
strokeDashoffset: `${circumference * (1 - progress / 100)}`,
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* 中心图标 */}
|
||||
<div className={styles.floatButtonContent}>
|
||||
<UnorderedListOutlined className={styles.icon} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default QuestionFloatButton
|
||||
@ -1,173 +0,0 @@
|
||||
.drawer {
|
||||
:global {
|
||||
.ant-drawer-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border-bottom: none;
|
||||
padding: 16px 24px;
|
||||
|
||||
.ant-drawer-title {
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.ant-drawer-close {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
|
||||
&:hover {
|
||||
color: white;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-drawer-body {
|
||||
padding: 16px 24px;
|
||||
|
||||
// 自定义滚动条样式
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: #f0f0f0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #bfbfbf;
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.groupsContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.questionGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.groupHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.typeTag {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.groupCount {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.numbersGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(36px, 1fr));
|
||||
gap: 10px;
|
||||
row-gap: 10px;
|
||||
}
|
||||
|
||||
.numberItem {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%; // 圆形
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #e8ecf1 100%);
|
||||
border: 2px solid transparent;
|
||||
color: #1d1d1f;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.groupDivider {
|
||||
margin: 8px 0;
|
||||
border-color: #e8e8e8;
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
.drawer {
|
||||
:global {
|
||||
.ant-drawer-content-wrapper {
|
||||
width: 85vw !important;
|
||||
max-width: 380px !important;
|
||||
}
|
||||
|
||||
.ant-drawer-header {
|
||||
padding: 14px 20px;
|
||||
}
|
||||
|
||||
.ant-drawer-body {
|
||||
padding: 14px 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.groupsContainer {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.typeTag {
|
||||
font-size: 12px;
|
||||
padding: 3px 10px;
|
||||
}
|
||||
|
||||
.groupCount {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.numbersGrid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(34px, 1fr));
|
||||
gap: 8px;
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
||||
.numberItem {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.groupDivider {
|
||||
margin: 6px 0;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,100 +0,0 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { Drawer, Tag, Divider, Space, Typography } from 'antd'
|
||||
import { BookOutlined } from '@ant-design/icons'
|
||||
import type { Question } from '../types/question'
|
||||
import styles from './QuestionListDrawer.module.less'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
interface QuestionListDrawerProps {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
questions: Question[]
|
||||
onQuestionSelect: (index: number) => void
|
||||
}
|
||||
|
||||
interface QuestionGroup {
|
||||
category: string
|
||||
items: Array<{ index: number; question: Question }>
|
||||
}
|
||||
|
||||
const QuestionListDrawer: React.FC<QuestionListDrawerProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
questions,
|
||||
onQuestionSelect,
|
||||
}) => {
|
||||
// 按分类分组
|
||||
const groupedQuestions = useMemo(() => {
|
||||
const groups: Record<string, QuestionGroup> = {}
|
||||
|
||||
questions.forEach((question, index) => {
|
||||
if (!groups[question.category]) {
|
||||
groups[question.category] = {
|
||||
category: question.category,
|
||||
items: [],
|
||||
}
|
||||
}
|
||||
groups[question.category].items.push({ index, question })
|
||||
})
|
||||
|
||||
return Object.values(groups)
|
||||
}, [questions])
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={
|
||||
<Space>
|
||||
<BookOutlined />
|
||||
<span>题目导航</span>
|
||||
<Text type="secondary" style={{ fontSize: 14, fontWeight: 'normal' }}>
|
||||
共 {questions.length} 题
|
||||
</Text>
|
||||
</Space>
|
||||
}
|
||||
placement="right"
|
||||
onClose={onClose}
|
||||
open={visible}
|
||||
width={500}
|
||||
className={styles.drawer}
|
||||
>
|
||||
<div className={styles.groupsContainer}>
|
||||
{groupedQuestions.map((group, groupIndex) => (
|
||||
<div key={group.category} className={styles.questionGroup}>
|
||||
{/* 分类标题 */}
|
||||
<div className={styles.groupHeader}>
|
||||
<Tag color="blue" className={styles.typeTag}>
|
||||
{group.category}
|
||||
</Tag>
|
||||
<span className={styles.groupCount}>共 {group.items.length} 题</span>
|
||||
</div>
|
||||
|
||||
{/* 题号列表 */}
|
||||
<div className={styles.numbersGrid}>
|
||||
{group.items.map(({ index, question }) => (
|
||||
<div
|
||||
key={question.id}
|
||||
className={styles.numberItem}
|
||||
onClick={() => {
|
||||
onQuestionSelect(index)
|
||||
onClose()
|
||||
}}
|
||||
title={`第 ${index + 1} 题`}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 分隔线 */}
|
||||
{groupIndex < groupedQuestions.length - 1 && (
|
||||
<Divider className={styles.groupDivider} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
export default QuestionListDrawer
|
||||
@ -1,38 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Progress } from 'antd'
|
||||
|
||||
interface QuestionProgressProps {
|
||||
currentIndex: number
|
||||
totalQuestions: number
|
||||
correctCount: number
|
||||
wrongCount: number
|
||||
}
|
||||
|
||||
const QuestionProgress: React.FC<QuestionProgressProps> = ({
|
||||
currentIndex,
|
||||
totalQuestions,
|
||||
}) => {
|
||||
if (totalQuestions === 0) return null
|
||||
|
||||
const percent = Math.round(((currentIndex + 1) / totalQuestions) * 100)
|
||||
|
||||
return (
|
||||
<Progress
|
||||
percent={percent}
|
||||
status="active"
|
||||
strokeColor={{
|
||||
'0%': '#007aff',
|
||||
'100%': '#52c41a',
|
||||
}}
|
||||
trailColor="rgba(0, 0, 0, 0.06)"
|
||||
strokeWidth={12}
|
||||
format={() => `${currentIndex + 1} / ${totalQuestions}`}
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default QuestionProgress
|
||||
@ -1,135 +0,0 @@
|
||||
# 答题组件说明
|
||||
|
||||
本目录包含答题功能的子组件,从原来的 `Question.tsx` 大组件拆分而来。
|
||||
|
||||
## 组件列表
|
||||
|
||||
### 1. QuestionProgress.tsx
|
||||
**功能**: 显示答题进度条和统计信息
|
||||
|
||||
**Props**:
|
||||
- `currentIndex`: number - 当前题目索引
|
||||
- `totalQuestions`: number - 总题目数
|
||||
- `correctCount`: number - 正确题目数
|
||||
- `wrongCount`: number - 错误题目数
|
||||
|
||||
**职责**:
|
||||
- 显示当前答题进度(百分比和题号)
|
||||
- 显示正确和错误的统计数量
|
||||
- 使用渐变色进度条增强视觉效果
|
||||
|
||||
---
|
||||
|
||||
### 2. QuestionCard.tsx
|
||||
**功能**: 显示单个题目的卡片,包含题目内容、选项和答案提交
|
||||
|
||||
**Props**:
|
||||
- `question`: Question - 题目对象
|
||||
- `selectedAnswer`: string | string[] - 选中的答案
|
||||
- `showResult`: boolean - 是否显示答题结果
|
||||
- `answerResult`: AnswerResult | null - 答题结果
|
||||
- `loading`: boolean - 提交加载状态
|
||||
- `autoNextLoading`: boolean - 自动下一题加载状态
|
||||
- `onAnswerChange`: (answer: string | string[]) => void - 答案变更回调
|
||||
- `onSubmit`: () => void - 提交答案回调
|
||||
- `onNext`: () => void - 下一题回调
|
||||
|
||||
**职责**:
|
||||
- 根据题目类型渲染不同的答题界面(单选、多选、填空、简答、判断)
|
||||
- 处理填空题的特殊渲染逻辑
|
||||
- 显示题目编号和分类标签
|
||||
- 显示答案结果(使用 AnswerResult 组件)
|
||||
- 提供提交和下一题按钮
|
||||
|
||||
---
|
||||
|
||||
### 3. AnswerResult.tsx
|
||||
**功能**: 显示答题结果的Alert组件
|
||||
|
||||
**Props**:
|
||||
- `answerResult`: AnswerResult - 答题结果对象
|
||||
- `selectedAnswer`: string | string[] - 用户选择的答案
|
||||
- `questionType`: string - 题目类型
|
||||
|
||||
**职责**:
|
||||
- 显示正确或错误的提示图标和颜色
|
||||
- 显示用户答案和正确答案
|
||||
- 显示答案解析(如果有)
|
||||
- 特殊处理判断题的答案显示(true/false → 正确/错误)
|
||||
|
||||
---
|
||||
|
||||
### 4. CompletionSummary.tsx
|
||||
**功能**: 完成所有题目后的统计摘要弹窗
|
||||
|
||||
**Props**:
|
||||
- `visible`: boolean - 弹窗是否可见
|
||||
- `totalQuestions`: number - 总题目数
|
||||
- `correctCount`: number - 正确数
|
||||
- `wrongCount`: number - 错误数
|
||||
- `category`: string | undefined - 题目类型分类
|
||||
- `onClose`: () => void - 关闭回调(返回首页)
|
||||
- `onRetry`: () => void - 重新开始回调
|
||||
|
||||
**职责**:
|
||||
- 显示完成奖杯图标
|
||||
- 展示本次答题的完整统计数据
|
||||
- 计算并显示正确率(根据正确率显示不同颜色)
|
||||
- 提供返回首页和重新开始两个操作
|
||||
|
||||
---
|
||||
|
||||
## 组件拆分的优势
|
||||
|
||||
1. **单一职责**: 每个组件只负责一个特定的功能
|
||||
2. **可维护性**: 更容易定位和修改问题
|
||||
3. **可测试性**: 每个组件可以独立测试
|
||||
4. **可复用性**: 组件可以在其他页面复用
|
||||
5. **代码清晰**: 主组件 Question.tsx 从 600+ 行缩减到 300 行左右
|
||||
|
||||
## 主组件 Question.tsx
|
||||
|
||||
**保留职责**:
|
||||
- 状态管理(题目、答案、进度等)
|
||||
- 业务逻辑(加载题目、提交答案、保存进度等)
|
||||
- API 调用
|
||||
- 组件组合和布局
|
||||
|
||||
**文件大小变化**:
|
||||
- 重构前: ~605 行
|
||||
- 重构后: ~303 行
|
||||
- 减少: ~50%
|
||||
|
||||
## 使用示例
|
||||
|
||||
```tsx
|
||||
// 在 Question.tsx 中使用
|
||||
<QuestionProgress
|
||||
currentIndex={currentIndex}
|
||||
totalQuestions={allQuestions.length}
|
||||
correctCount={correctCount}
|
||||
wrongCount={wrongCount}
|
||||
/>
|
||||
|
||||
<QuestionCard
|
||||
question={currentQuestion}
|
||||
selectedAnswer={selectedAnswer}
|
||||
showResult={showResult}
|
||||
answerResult={answerResult}
|
||||
loading={loading}
|
||||
autoNextLoading={autoNextLoading}
|
||||
onAnswerChange={setSelectedAnswer}
|
||||
onSubmit={handleSubmit}
|
||||
onNext={handleNext}
|
||||
/>
|
||||
|
||||
<CompletionSummary
|
||||
visible={showSummary}
|
||||
totalQuestions={allQuestions.length}
|
||||
correctCount={correctCount}
|
||||
wrongCount={wrongCount}
|
||||
category={currentQuestion?.category}
|
||||
onClose={() => navigate("/")}
|
||||
onRetry={handleRetry}
|
||||
/>
|
||||
```
|
||||
21
web/src/components/TabBarLayout.less
Normal file
21
web/src/components/TabBarLayout.less
Normal file
@ -0,0 +1,21 @@
|
||||
// 布局容器
|
||||
.tab-bar-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
// 内容区域
|
||||
.tab-bar-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
// 底部导航栏
|
||||
.tab-bar-footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
@ -1,70 +0,0 @@
|
||||
.layout {
|
||||
min-height: 100vh;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
padding: 0;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(40px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(40px) saturate(180%);
|
||||
box-shadow:
|
||||
0 -2px 8px rgba(0, 0, 0, 0.04),
|
||||
0 -1px 4px rgba(0, 0, 0, 0.02),
|
||||
0 0 0 1px rgba(0, 0, 0, 0.03);
|
||||
border-top: 0.5px solid rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.menu {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
border-bottom: none;
|
||||
|
||||
:global {
|
||||
.ant-menu-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 60px;
|
||||
margin: 0;
|
||||
padding: 8px 0;
|
||||
|
||||
.anticon {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-menu-item-selected {
|
||||
background-color: transparent;
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计 - 移动端
|
||||
@media (max-width: 768px) {
|
||||
.footer {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计 - PC端
|
||||
@media (min-width: 769px) {
|
||||
.footer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@ -1,18 +1,52 @@
|
||||
import React from 'react'
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { Layout } from 'antd'
|
||||
import styles from './TabBarLayout.module.less'
|
||||
|
||||
const { Content } = Layout
|
||||
import { useNavigate, useLocation, Outlet } from 'react-router-dom'
|
||||
import { TabBar } from 'antd-mobile'
|
||||
import {
|
||||
AppOutline,
|
||||
UserOutline,
|
||||
UnorderedListOutline,
|
||||
} from 'antd-mobile-icons'
|
||||
import './TabBarLayout.less'
|
||||
|
||||
const TabBarLayout: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
key: '/',
|
||||
title: '首页',
|
||||
icon: <AppOutline />,
|
||||
},
|
||||
{
|
||||
key: '/question',
|
||||
title: '答题',
|
||||
icon: <UnorderedListOutline />,
|
||||
},
|
||||
{
|
||||
key: '/profile',
|
||||
title: '我的',
|
||||
icon: <UserOutline />,
|
||||
},
|
||||
]
|
||||
|
||||
const setRouteActive = (value: string) => {
|
||||
navigate(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout className={styles.layout}>
|
||||
<Content className={styles.content}>
|
||||
<div className="tab-bar-layout">
|
||||
<div className="tab-bar-content">
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</div>
|
||||
<div className="tab-bar-footer">
|
||||
<TabBar activeKey={location.pathname} onChange={setRouteActive}>
|
||||
{tabs.map(item => (
|
||||
<TabBar.Item key={item.key} icon={item.icon} title={item.title} />
|
||||
))}
|
||||
</TabBar>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,65 +1,17 @@
|
||||
// 变量
|
||||
@bg-color: #f5f5f5;
|
||||
|
||||
// 容器
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
background: #fafafa;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 20px;
|
||||
padding-bottom: 40px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
padding: 16px 0;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background-color: @bg-color;
|
||||
}
|
||||
|
||||
.card {
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(40px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(40px) saturate(180%);
|
||||
box-shadow:
|
||||
0 2px 16px rgba(0, 0, 0, 0.06),
|
||||
0 1px 8px rgba(0, 0, 0, 0.04),
|
||||
0 0 0 1px rgba(0, 0, 0, 0.03);
|
||||
border: 0.5px solid rgba(0, 0, 0, 0.04);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.homeButton {
|
||||
height: 48px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
border-radius: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
// 响应式设计 - 移动端
|
||||
@media (max-width: 768px) {
|
||||
.content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border-radius: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.homeButton {
|
||||
height: 44px;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计 - PC端
|
||||
@media (min-width: 769px) {
|
||||
.content {
|
||||
padding: 32px;
|
||||
}
|
||||
// 内容区域
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
@ -1,61 +1,45 @@
|
||||
import React from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Card, List, Button, Typography, Descriptions } from 'antd'
|
||||
import { LeftOutlined } from '@ant-design/icons'
|
||||
import { NavBar, Card, List, Space, Button } from 'antd-mobile'
|
||||
import styles from './About.module.less'
|
||||
|
||||
const { Title } = Typography
|
||||
|
||||
const About: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<NavBar onBack={() => navigate(-1)}>关于</NavBar>
|
||||
|
||||
<div className={styles.content}>
|
||||
<div className={styles.header}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<LeftOutlined />}
|
||||
onClick={() => navigate(-1)}
|
||||
style={{ color: 'white' }}
|
||||
>
|
||||
返回
|
||||
</Button>
|
||||
<Title level={3} style={{ color: 'white', margin: 0 }}>关于</Title>
|
||||
<div style={{ width: 48 }} />
|
||||
</div>
|
||||
<Card title="项目信息">
|
||||
<Space direction="vertical" block>
|
||||
<List>
|
||||
<List.Item extra="1.0.0">版本</List.Item>
|
||||
<List.Item extra="AnKao Team">开发者</List.Item>
|
||||
<List.Item extra="MIT">许可证</List.Item>
|
||||
</List>
|
||||
|
||||
<Card title="项目信息" className={styles.card}>
|
||||
<Descriptions column={1}>
|
||||
<Descriptions.Item label="版本">1.0.0</Descriptions.Item>
|
||||
<Descriptions.Item label="开发者">AnKao Team</Descriptions.Item>
|
||||
<Descriptions.Item label="许可证">MIT</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Card title="功能特性">
|
||||
<List>
|
||||
<List.Item>✅ 响应式移动端设计</List.Item>
|
||||
<List.Item>✅ TypeScript 类型安全</List.Item>
|
||||
<List.Item>✅ Vite 快速构建</List.Item>
|
||||
<List.Item>✅ antd-mobile 组件库</List.Item>
|
||||
<List.Item>✅ React Router 路由</List.Item>
|
||||
<List.Item>✅ API 代理配置</List.Item>
|
||||
</List>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
color="primary"
|
||||
size="large"
|
||||
block
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
返回首页
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card title="功能特性" className={styles.card}>
|
||||
<List
|
||||
dataSource={[
|
||||
'✅ 响应式设计(支持移动端和PC端)',
|
||||
'✅ TypeScript 类型安全',
|
||||
'✅ Vite 快速构建',
|
||||
'✅ Ant Design 组件库',
|
||||
'✅ React Router 路由',
|
||||
'✅ API 代理配置',
|
||||
]}
|
||||
renderItem={(item) => <List.Item>{item}</List.Item>}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
onClick={() => navigate('/')}
|
||||
className={styles.homeButton}
|
||||
>
|
||||
返回首页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,378 +0,0 @@
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
font-size: 14px; // 设置基础字体大小
|
||||
|
||||
@media print {
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.loadingContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.statsCard {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.detailCard {
|
||||
margin-bottom: 24px;
|
||||
|
||||
// 适合打印的样式
|
||||
@media print {
|
||||
box-shadow: none;
|
||||
border: 1px solid #000;
|
||||
}
|
||||
}
|
||||
|
||||
.actionsCard {
|
||||
text-align: center;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.questionCard {
|
||||
margin-bottom: 16px;
|
||||
|
||||
@media print {
|
||||
box-shadow: none;
|
||||
border: 1px solid #ccc;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
}
|
||||
|
||||
.answerDetail {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.questionContent {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.answerSection {
|
||||
background: #f8f9fa;
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
|
||||
@media print {
|
||||
background: none;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
}
|
||||
|
||||
.answerItem {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.correct {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.incorrect {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.typeScoreCard {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.typeScoreItem {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.typeScoreHeader {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.typeScoreContent {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.typeScoreProgress {
|
||||
height: 6px;
|
||||
background: #f0f0f0;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.typeScoreBar {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
// 打印样式优化
|
||||
@media print {
|
||||
.container {
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-size: 12pt;
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
page-break-after: avoid;
|
||||
}
|
||||
|
||||
p {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
// 优化打印时的卡片显示
|
||||
.detailCard {
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
margin-bottom: 0;
|
||||
|
||||
@media print {
|
||||
// 打印时移除卡片头部的内边距
|
||||
.ant-card-head {
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
// 移除卡片内容区域的内边距
|
||||
.ant-card-body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// 避免在卡片前后分页
|
||||
page-break-inside: avoid;
|
||||
page-break-after: avoid;
|
||||
}
|
||||
}
|
||||
|
||||
// 打印时优化Card显示
|
||||
.detailCard {
|
||||
@media print {
|
||||
.ant-card-body {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 确保题型标题在打印时不会被分页打断
|
||||
.typeTitle {
|
||||
page-break-after: avoid;
|
||||
page-break-inside: avoid;
|
||||
font-size: 16px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
@media print {
|
||||
font-size: 14px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// 优化打印时的分隔线
|
||||
.ant-divider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// 打印时隐藏分隔线容器的外边距
|
||||
.ant-divider-horizontal {
|
||||
margin: 16px 0;
|
||||
|
||||
@media print {
|
||||
margin: 0;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// 专门用于隐藏打印时分隔线的类
|
||||
.noPrintDivider {
|
||||
@media print {
|
||||
display: none;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 隐藏打印按钮所在的卡片
|
||||
.statsCard {
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
margin-bottom: 0;
|
||||
|
||||
@media print {
|
||||
margin-bottom: 0;
|
||||
padding: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
// 隐藏返回按钮和打印按钮
|
||||
button {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 防止最后一页出现空白页
|
||||
.fillBlankContainer,
|
||||
.fillBlankItem,
|
||||
.optionsTable {
|
||||
page-break-inside: avoid;
|
||||
page-break-after: avoid;
|
||||
}
|
||||
|
||||
// 移除页面底部可能的空白
|
||||
.container:last-child {
|
||||
page-break-after: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// 填空题容器样式
|
||||
.fillBlankContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 14px;
|
||||
|
||||
@media print {
|
||||
gap: 2px;
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 填空题项目样式
|
||||
.fillBlankItem {
|
||||
page-break-inside: avoid;
|
||||
padding: 4px 0;
|
||||
|
||||
@media print {
|
||||
page-break-inside: avoid;
|
||||
padding: 2px 0;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 表格样式用于选择题
|
||||
.optionsTable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 8px;
|
||||
table-layout: fixed; // 固定表格布局,确保列宽一致
|
||||
|
||||
th, td {
|
||||
border: none;
|
||||
padding: 8px 4px;
|
||||
text-align: center;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #f5f5f5;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@media print {
|
||||
th, td {
|
||||
border: none;
|
||||
padding: 6px 2px;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
// 打印时增加分页控制
|
||||
tr {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 选择题答案显示
|
||||
.choiceAnswer {
|
||||
font-weight: bold;
|
||||
color: #1890ff;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
// 题号和答案的显示样式
|
||||
.answerDetail {
|
||||
text-align: left;
|
||||
padding: 4px 0;
|
||||
font-size: 14px; // 调整字体大小以适应打印
|
||||
line-height: 1.4;
|
||||
word-wrap: break-word; // 自动换行
|
||||
word-break: break-all;
|
||||
|
||||
@media print {
|
||||
font-size: 12px; // 打印时使用稍小的字体
|
||||
padding: 2px 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
}
|
||||
|
||||
// 表格样式优化
|
||||
.optionsTable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 8px;
|
||||
table-layout: fixed; // 固定表格布局,确保列宽一致
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
|
||||
th, td {
|
||||
border: none;
|
||||
padding: 12px 8px;
|
||||
text-align: center;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #f5f5f5;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@media print {
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
th, td {
|
||||
border: none;
|
||||
padding: 8px 4px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
// 打印时增加分页控制
|
||||
tr {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
// 避免表格后产生空白页
|
||||
tbody:last-child {
|
||||
page-break-after: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,291 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Typography,
|
||||
Space,
|
||||
Spin,
|
||||
Row,
|
||||
Col,
|
||||
Divider,
|
||||
message
|
||||
} from 'antd'
|
||||
import {
|
||||
LeftOutlined
|
||||
} from '@ant-design/icons'
|
||||
import * as examApi from '../api/exam'
|
||||
import type { Question } from '../types/question'
|
||||
import type { GetExamResponse } from '../types/exam'
|
||||
import styles from './ExamAnswerView.module.less'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
// 题型名称映射
|
||||
const TYPE_NAME: Record<string, string> = {
|
||||
'fill-in-blank': '填空题',
|
||||
'true-false': '判断题',
|
||||
'multiple-choice': '单选题',
|
||||
'multiple-selection': '多选题',
|
||||
'short-answer': '简答题',
|
||||
'ordinary-essay': '论述题',
|
||||
'management-essay': '论述题',
|
||||
'essay': '论述题' // 合并后的论述题类型
|
||||
}
|
||||
|
||||
// 题型顺序定义
|
||||
const TYPE_ORDER: Record<string, number> = {
|
||||
'fill-in-blank': 1,
|
||||
'true-false': 2,
|
||||
'multiple-choice': 3,
|
||||
'multiple-selection': 4,
|
||||
'short-answer': 5,
|
||||
'ordinary-essay': 6,
|
||||
'management-essay': 6,
|
||||
'essay': 6 // 合并后的论述题顺序
|
||||
}
|
||||
|
||||
const ExamAnswerView: React.FC = () => {
|
||||
const { examId } = useParams<{ examId: string }>()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [examData, setExamData] = useState<GetExamResponse | null>(null)
|
||||
const [questions, setQuestions] = useState<Question[]>([])
|
||||
|
||||
// 处理打印功能
|
||||
const handlePrint = () => {
|
||||
// 设置打印标题
|
||||
document.title = `试卷答案_打印版`
|
||||
|
||||
// 触发打印
|
||||
window.print()
|
||||
|
||||
// 打印完成后恢复标题
|
||||
setTimeout(() => {
|
||||
document.title = 'AnKao - 智能考试系统'
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!examId) {
|
||||
message.error('参数错误')
|
||||
navigate('/exam/management')
|
||||
return
|
||||
}
|
||||
|
||||
loadExamData()
|
||||
}, [examId])
|
||||
|
||||
const loadExamData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// 获取带答案的试卷详情
|
||||
const res = await examApi.getExam(Number(examId), true)
|
||||
|
||||
if (res.success && res.data) {
|
||||
setExamData(res.data)
|
||||
setQuestions(res.data.questions)
|
||||
} else {
|
||||
message.error('加载试卷失败')
|
||||
navigate('/exam/management')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.message || '加载试卷失败')
|
||||
navigate('/exam/management')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.loadingContainer}>
|
||||
<Spin size="large" />
|
||||
<Text style={{ marginTop: 16 }}>加载试卷中...</Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!examData) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 渲染答案详情
|
||||
const renderAnswerDetail = (question: Question, index: number) => {
|
||||
// 格式化答案显示
|
||||
const formatAnswer = (answer: any, type: string): string => {
|
||||
if (answer === null || answer === undefined || answer === '') {
|
||||
return '未设置答案'
|
||||
}
|
||||
|
||||
if (Array.isArray(answer)) {
|
||||
if (answer.length === 0) return '未设置答案'
|
||||
return answer.filter(a => a !== null && a !== undefined && a !== '').join('、')
|
||||
}
|
||||
|
||||
if (type === 'true-false') {
|
||||
// 处理判断题:支持字符串和布尔值
|
||||
const answerStr = String(answer).toLowerCase()
|
||||
return answerStr === 'true' ? '正确' : '错误'
|
||||
}
|
||||
|
||||
return String(answer)
|
||||
}
|
||||
|
||||
// 特殊处理填空题,按要求格式显示
|
||||
if (question.type === 'fill-in-blank') {
|
||||
const answers = Array.isArray(question.answer) ? question.answer : [question.answer];
|
||||
// 过滤掉空答案并转换为字符串
|
||||
const validAnswers = answers
|
||||
.filter(answer => answer !== null && answer !== undefined && answer !== '')
|
||||
.map(answer => String(answer));
|
||||
|
||||
// 如果没有有效答案,显示提示
|
||||
if (validAnswers.length === 0) {
|
||||
return (
|
||||
<div className={styles.answerDetail}>
|
||||
{index + 1}. (无答案)
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 使用逗号分隔显示答案
|
||||
return (
|
||||
<div className={styles.answerDetail}>
|
||||
{index + 1}. {validAnswers.join(',')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 特殊处理判断题,显示对号或X
|
||||
if (question.type === 'true-false') {
|
||||
const answerStr = String(question.answer).toLowerCase();
|
||||
const isCorrect = answerStr === 'true';
|
||||
return (
|
||||
<div className={styles.answerDetail}>
|
||||
{index + 1}. {isCorrect ? '√' : '×'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 其他题型显示题号和答案
|
||||
return (
|
||||
<div className={styles.answerDetail}>
|
||||
{index + 1}. {formatAnswer(question.answer, question.type)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 汉字数字映射
|
||||
const chineseNumbers = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
|
||||
|
||||
// 按题型分组(合并两种论述题)
|
||||
const groupedQuestions = questions.reduce((acc, q) => {
|
||||
// 将两种论述题统一为 'essay'
|
||||
const displayType = (q.type === 'ordinary-essay' || q.type === 'management-essay') ? 'essay' : q.type
|
||||
if (!acc[displayType]) {
|
||||
acc[displayType] = []
|
||||
}
|
||||
acc[displayType].push(q)
|
||||
return acc
|
||||
}, {} as Record<string, Question[]>)
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* 试卷标题和返回按钮 */}
|
||||
<Card className={styles.statsCard}>
|
||||
<Row align="middle" justify="space-between">
|
||||
<Col>
|
||||
<Button
|
||||
icon={<LeftOutlined />}
|
||||
onClick={() => navigate('/exam/management')}
|
||||
>
|
||||
返回
|
||||
</Button>
|
||||
</Col>
|
||||
<Col flex="auto" style={{ textAlign: 'center' }}>
|
||||
<Text strong style={{ fontSize: 20 }}>
|
||||
试卷答案
|
||||
</Text>
|
||||
</Col>
|
||||
<Col>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handlePrint}
|
||||
>
|
||||
打印
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 答题详情 - 使用表格展示 */}
|
||||
<Card className={styles.detailCard}>
|
||||
{Object.entries(groupedQuestions)
|
||||
.sort(([typeA], [typeB]) => {
|
||||
const orderA = TYPE_ORDER[typeA] || 999
|
||||
const orderB = TYPE_ORDER[typeB] || 999
|
||||
return orderA - orderB
|
||||
})
|
||||
.map(([type, qs], typeIndex) => (
|
||||
<div key={type} style={{ marginBottom: 16 }}>
|
||||
{/* 题型标题 */}
|
||||
<div className={styles.typeTitle} style={{
|
||||
padding: '8px 12px',
|
||||
marginBottom: 8
|
||||
}}>
|
||||
<Space>
|
||||
<Text strong style={{ fontSize: 16 }}>
|
||||
{chineseNumbers[typeIndex] || typeIndex + 1}、{TYPE_NAME[type] || type}
|
||||
</Text>
|
||||
<Text type="secondary">(共 {qs.length} 题)</Text>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 填空题、简答题和论述题特殊处理:每行一个答案,不使用表格 */}
|
||||
{type === 'fill-in-blank' || type === 'short-answer' || type === 'essay' || type === 'ordinary-essay' || type === 'management-essay' ? (
|
||||
<div className={styles.fillBlankContainer}>
|
||||
{qs.map((q, index) => (
|
||||
<div key={q.id} className={styles.fillBlankItem}>
|
||||
{renderAnswerDetail(q, index)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* 其他题型使用表格显示答案,每行5列,确保题号和答案分行显示 */
|
||||
<table className={styles.optionsTable}>
|
||||
<tbody>
|
||||
{/* 每5个题目为一行,确保更好的打印效果 */}
|
||||
{Array.from({ length: Math.ceil(qs.length / 5) }).map((_, rowIndex) => (
|
||||
<tr key={rowIndex}>
|
||||
{qs.slice(rowIndex * 5, (rowIndex + 1) * 5).map((q, colIndex) => {
|
||||
const globalIndex = rowIndex * 5 + colIndex;
|
||||
return (
|
||||
<td key={q.id} style={{ width: '20%' }}>
|
||||
{renderAnswerDetail(q, globalIndex)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
{/* 如果最后一行不足5列,用空单元格填充 */}
|
||||
{qs.length - rowIndex * 5 < 5 &&
|
||||
Array.from({ length: 5 - (qs.length - rowIndex * 5) }).map((_, emptyIndex) => (
|
||||
<td key={`empty-${emptyIndex}`} style={{ width: '20%' }}></td>
|
||||
))
|
||||
}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{/* 题型之间的分隔线 */}
|
||||
<Divider className={styles.noPrintDivider} />
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExamAnswerView
|
||||
@ -1,647 +0,0 @@
|
||||
.container {
|
||||
padding: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
background: #f5f7fa;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 32px;
|
||||
padding: 20px 24px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
// 试卷网格布局
|
||||
.examGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr); // 固定显示3列
|
||||
gap: 24px;
|
||||
align-items: stretch; // 确保所有卡片等高
|
||||
}
|
||||
|
||||
// 试卷卡片样式重构
|
||||
.examCard {
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||
border: 1px solid #e8eaed;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
background: white;
|
||||
height: 100%; // 确保卡片占满网格单元格高度
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
:global(.ant-card-body) {
|
||||
padding: 0;
|
||||
flex: 1; // 让卡片内容自动伸展
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
// 卡片头部样式 - 使用蓝色主题
|
||||
.cardCover {
|
||||
position: relative;
|
||||
padding: 28px 24px;
|
||||
color: white;
|
||||
min-height: 140px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
background: linear-gradient(135deg, #4A90E2 0%, #357ABD 100%);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(135deg,
|
||||
rgba(74, 144, 226, 0.95) 0%,
|
||||
rgba(53, 122, 189, 0.95) 50%,
|
||||
rgba(41, 98, 155, 0.95) 100%
|
||||
);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -20px;
|
||||
left: -20px;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.coverInfo {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
text-align: left;
|
||||
|
||||
.examTitle {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
color: white;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.shareTag {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
color: white;
|
||||
backdrop-filter: blur(12px);
|
||||
font-weight: 500;
|
||||
border-radius: 20px;
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.coverIcon {
|
||||
position: absolute;
|
||||
top: 28px;
|
||||
right: 24px;
|
||||
z-index: 2;
|
||||
font-size: 36px;
|
||||
opacity: 0.95;
|
||||
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.2));
|
||||
}
|
||||
|
||||
// 卡片内容样式
|
||||
.cardContent {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.examInfo {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
gap: 12px;
|
||||
|
||||
.infoItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
padding: 16px 12px;
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: #e2e8f0;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.infoIcon {
|
||||
font-size: 18px;
|
||||
color: #4A90E2;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.infoText {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin: 16px 0;
|
||||
border-color: #e2e8f0;
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
// 统计数据样式
|
||||
.examStats {
|
||||
display: flex;
|
||||
flex-direction: row; // 改为横向排列
|
||||
flex-wrap: wrap; // 允许换行
|
||||
gap: 12px;
|
||||
|
||||
.statItem {
|
||||
flex: 1; // 让每个项目平均分配空间
|
||||
min-width: 0; // 允许flex收缩
|
||||
|
||||
.valueTag {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
border: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
line-height: 1.4;
|
||||
width: 100%;
|
||||
justify-content: center; // 内容居中
|
||||
|
||||
// 覆盖 antd Tag 的默认图标间距
|
||||
:global(.anticon) + span,
|
||||
span + :global(.anticon) {
|
||||
margin-inline-start: 0 !important;
|
||||
}
|
||||
|
||||
.anticon {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.progressTag {
|
||||
font-size: 13px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
line-height: 1.4;
|
||||
width: 100%;
|
||||
justify-content: center; // 内容居中
|
||||
|
||||
// 覆盖 antd Tag 的默认图标间距
|
||||
:global(.anticon) + span,
|
||||
span + :global(.anticon) {
|
||||
margin-inline-start: 0 !important;
|
||||
}
|
||||
|
||||
.anticon {
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 操作按钮样式 - 初版
|
||||
.actionButton {
|
||||
width: 100%;
|
||||
height: 50px !important;
|
||||
border-radius: 6px !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 500 !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
gap: 6px !important;
|
||||
border: 1px solid #d9d9d9 !important;
|
||||
background: #ffffff !important;
|
||||
color: #666666 !important;
|
||||
transition: all 0.3s ease !important;
|
||||
margin: 0 !important;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05) !important;
|
||||
|
||||
&:hover {
|
||||
background: #1890ff !important;
|
||||
color: #ffffff !important;
|
||||
border-color: #1890ff !important;
|
||||
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.15) !important;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.anticon {
|
||||
font-size: 18px !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
&.ant-btn-dangerous {
|
||||
color: #ff4d4f !important;
|
||||
border-color: #ff4d4f !important;
|
||||
|
||||
&:hover {
|
||||
background: #ff4d4f !important;
|
||||
color: #ffffff !important;
|
||||
border-color: #ff4d4f !important;
|
||||
box-shadow: 0 4px 12px rgba(255, 77, 79, 0.15) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 卡片操作区域样式 - 初版
|
||||
:global(.ant-card-actions) {
|
||||
background: #fafafa;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding: 12px 16px;
|
||||
|
||||
li {
|
||||
margin: 0 !important;
|
||||
padding: 8px 6px !important;
|
||||
border-right: none !important;
|
||||
|
||||
.actionButton {
|
||||
border-radius: 6px !important;
|
||||
background: #ffffff !important;
|
||||
border-color: #d9d9d9 !important;
|
||||
color: #666666 !important;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05) !important;
|
||||
|
||||
&:hover {
|
||||
background: #1890ff !important;
|
||||
border-color: #1890ff !important;
|
||||
color: #ffffff !important;
|
||||
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.15) !important;
|
||||
}
|
||||
|
||||
&.ant-btn-dangerous {
|
||||
color: #ff4d4f !important;
|
||||
border-color: #ff4d4f !important;
|
||||
|
||||
&:hover {
|
||||
background: #ff4d4f !important;
|
||||
color: #ffffff !important;
|
||||
border-color: #ff4d4f !important;
|
||||
box-shadow: 0 4px 12px rgba(255, 77, 79, 0.15) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 旧版兼容样式
|
||||
.cardTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
|
||||
svg {
|
||||
font-size: 18px;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
span {
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.cardContent {
|
||||
|
||||
.infoRow {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.infoItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
|
||||
svg {
|
||||
color: #1890ff;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.statTag {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
// 旧版兼容样式 - divider已合并,不再重复定义
|
||||
|
||||
// 响应式适配
|
||||
// 移动端:1列
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 16px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.examGrid {
|
||||
grid-template-columns: 1fr; // 移动端显示1列
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
h2 {
|
||||
font-size: 22px;
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
button {
|
||||
width: auto;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
padding: 0 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.examCard {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.cardCover {
|
||||
padding: 24px 20px;
|
||||
min-height: 120px;
|
||||
|
||||
.coverIcon {
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
right: 20px;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.examTitle {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.cardContent {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.examInfo {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.infoItem {
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
padding: 12px 16px;
|
||||
gap: 12px;
|
||||
|
||||
.infoIcon {
|
||||
font-size: 16px;
|
||||
margin-bottom: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.infoText {
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.examStats {
|
||||
padding: 12px 0;
|
||||
gap: 6px;
|
||||
|
||||
.statItem {
|
||||
.valueTag {
|
||||
font-size: 12px;
|
||||
padding: 6px 10px;
|
||||
gap: 6px;
|
||||
|
||||
.anticon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.progressTag {
|
||||
font-size: 12px;
|
||||
padding: 6px 10px;
|
||||
gap: 6px;
|
||||
|
||||
.anticon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
height: 46px !important;
|
||||
font-size: 12px !important;
|
||||
|
||||
.anticon {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 移动端操作区域
|
||||
:global(.ant-card-actions) {
|
||||
padding: 10px 12px;
|
||||
|
||||
li {
|
||||
padding: 6px 4px !important;
|
||||
|
||||
.actionButton {
|
||||
height: 46px !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 移动端旧版样式
|
||||
.cardTitle {
|
||||
font-size: 15px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
svg {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.cardContent {
|
||||
margin-top: 8px;
|
||||
|
||||
.infoRow {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.infoItem {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.stats {
|
||||
margin-top: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.statTag {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
:global(.ant-card-actions) {
|
||||
li {
|
||||
button {
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 平板端:2列
|
||||
@media (min-width: 769px) and (max-width: 1024px) {
|
||||
.examGrid {
|
||||
grid-template-columns: repeat(2, 1fr); // 平板显示2列
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.examGrid {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 12px 16px;
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
button {
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.cardCover {
|
||||
padding: 20px 16px;
|
||||
min-height: 100px;
|
||||
|
||||
.coverIcon {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 16px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.examTitle {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.cardContent {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
:global(.ant-card-actions) {
|
||||
li {
|
||||
.actionButton {
|
||||
height: 42px !important;
|
||||
font-size: 11px !important;
|
||||
|
||||
.anticon {
|
||||
font-size: 14px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,648 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
Space,
|
||||
message,
|
||||
List,
|
||||
Tag,
|
||||
Modal,
|
||||
Empty,
|
||||
Spin,
|
||||
Drawer,
|
||||
Descriptions,
|
||||
Divider,
|
||||
Checkbox,
|
||||
Avatar
|
||||
} from 'antd'
|
||||
import {
|
||||
PlusOutlined,
|
||||
DeleteOutlined,
|
||||
PlayCircleOutlined,
|
||||
FileTextOutlined,
|
||||
TrophyOutlined,
|
||||
HistoryOutlined,
|
||||
PrinterOutlined,
|
||||
ArrowLeftOutlined,
|
||||
ShareAltOutlined,
|
||||
UserOutlined,
|
||||
TeamOutlined,
|
||||
CrownOutlined
|
||||
} from '@ant-design/icons'
|
||||
import * as examApi from '../api/exam'
|
||||
import styles from './ExamManagement.module.less'
|
||||
|
||||
interface ExamListItem {
|
||||
id: number
|
||||
title: string
|
||||
total_score: number
|
||||
duration: number
|
||||
pass_score: number
|
||||
question_count: number
|
||||
attempt_count: number
|
||||
best_score: number
|
||||
has_in_progress_exam: boolean
|
||||
in_progress_record_id?: number
|
||||
participant_count: number // 共享试卷的参与人数
|
||||
created_at: string
|
||||
is_shared?: boolean
|
||||
is_system?: boolean // 是否为系统试卷
|
||||
shared_by?: {
|
||||
id: number
|
||||
username: string
|
||||
nickname?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface ShareableUser {
|
||||
id: number
|
||||
username: string
|
||||
nickname?: string
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
const ExamManagement: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const [form] = Form.useForm()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [exams, setExams] = useState<ExamListItem[]>([])
|
||||
const [loadingExams, setLoadingExams] = useState(false)
|
||||
const [createModalVisible, setCreateModalVisible] = useState(false)
|
||||
const [recordsDrawerVisible, setRecordsDrawerVisible] = useState(false)
|
||||
const [, setCurrentExamId] = useState<number | null>(null)
|
||||
const [examRecords, setExamRecords] = useState<any[]>([])
|
||||
const [loadingRecords, setLoadingRecords] = useState(false)
|
||||
|
||||
// 分享相关状态
|
||||
const [shareModalVisible, setShareModalVisible] = useState(false)
|
||||
const [shareableUsers, setShareableUsers] = useState<ShareableUser[]>([])
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([])
|
||||
const [loadingUsers, setLoadingUsers] = useState(false)
|
||||
const [sharingExamId, setSharingExamId] = useState<number | null>(null)
|
||||
const [sharingLoading, setSharingLoading] = useState(false)
|
||||
|
||||
// 加载试卷列表
|
||||
const loadExams = async () => {
|
||||
setLoadingExams(true)
|
||||
try {
|
||||
const res = await examApi.getExamList()
|
||||
if (res.success) {
|
||||
setExams(res.data || [])
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('加载试卷列表失败')
|
||||
} finally {
|
||||
setLoadingExams(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadExams()
|
||||
}, [])
|
||||
|
||||
// 创建试卷
|
||||
const handleCreateExam = async (values: any) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = {
|
||||
title: values.title,
|
||||
duration: 60, // 默认60分钟
|
||||
question_types: [] // 空配置,后端会使用默认值
|
||||
}
|
||||
|
||||
const res = await examApi.createExam(params)
|
||||
if (res.success) {
|
||||
message.success('试卷创建成功')
|
||||
setCreateModalVisible(false)
|
||||
form.resetFields()
|
||||
loadExams()
|
||||
} else {
|
||||
message.error(res.message || '创建失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.response?.data?.message) {
|
||||
message.error(error.response.data.message)
|
||||
} else {
|
||||
message.error('创建失败,请稍后重试')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除试卷
|
||||
const handleDeleteExam = async (examId: number) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '删除试卷后将无法恢复,是否确认删除?',
|
||||
okText: '确认',
|
||||
cancelText: '取消',
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
try {
|
||||
const res = await examApi.deleteExam(examId)
|
||||
if (res.success) {
|
||||
message.success('删除成功')
|
||||
loadExams()
|
||||
} else {
|
||||
message.error(res.message || '删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 开始考试
|
||||
const handleStartExam = async (examId: number, hasInProgressExam: boolean, inProgressRecordId?: number) => {
|
||||
try {
|
||||
if (hasInProgressExam && inProgressRecordId) {
|
||||
// 有未完成的考试,直接跳转继续答题
|
||||
navigate(`/exam/${examId}/taking/${inProgressRecordId}`)
|
||||
} else {
|
||||
// 没有未完成的考试,调用开始考试API创建新记录
|
||||
const res = await examApi.startExam(examId)
|
||||
if (res.success && res.data) {
|
||||
navigate(`/exam/${examId}/taking/${res.data.record_id}`)
|
||||
} else {
|
||||
message.error(res.message || '开始考试失败')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('开始考试失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 查看考试记录
|
||||
const handleViewRecords = async (examId: number) => {
|
||||
setCurrentExamId(examId)
|
||||
setRecordsDrawerVisible(true)
|
||||
setLoadingRecords(true)
|
||||
try {
|
||||
const res = await examApi.getExamRecordList(examId)
|
||||
if (res.success && res.data) {
|
||||
setExamRecords(res.data)
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('加载考试记录失败')
|
||||
} finally {
|
||||
setLoadingRecords(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 查看记录详情
|
||||
const handleViewRecordDetail = (recordId: number) => {
|
||||
navigate(`/exam/result/${recordId}`)
|
||||
}
|
||||
|
||||
// 打开分享弹窗
|
||||
const handleOpenShareModal = async (examId: number) => {
|
||||
setSharingExamId(examId)
|
||||
setShareModalVisible(true)
|
||||
setSelectedUserIds([])
|
||||
|
||||
// 加载可分享的用户列表
|
||||
setLoadingUsers(true)
|
||||
try {
|
||||
const res = await examApi.getShareableUsers()
|
||||
if (res.success && res.data) {
|
||||
setShareableUsers(res.data)
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('加载用户列表失败')
|
||||
} finally {
|
||||
setLoadingUsers(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理分享
|
||||
const handleShareExam = async () => {
|
||||
if (!sharingExamId || selectedUserIds.length === 0) {
|
||||
message.warning('请至少选择一个用户')
|
||||
return
|
||||
}
|
||||
|
||||
setSharingLoading(true)
|
||||
try {
|
||||
const res = await examApi.shareExam(sharingExamId, selectedUserIds)
|
||||
if (res.success) {
|
||||
message.success(res.message || `成功分享给 ${res.data?.shared_count || selectedUserIds.length} 个用户`)
|
||||
setShareModalVisible(false)
|
||||
setSelectedUserIds([])
|
||||
setSharingExamId(null)
|
||||
} else {
|
||||
message.error(res.message || '分享失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.response?.data?.message) {
|
||||
message.error(error.response.data.message)
|
||||
} else {
|
||||
message.error('分享失败,请稍后重试')
|
||||
}
|
||||
} finally {
|
||||
setSharingLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理用户选择变化
|
||||
const handleUserSelectionChange = (userId: number, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedUserIds([...selectedUserIds, userId])
|
||||
} else {
|
||||
setSelectedUserIds(selectedUserIds.filter(id => id !== userId))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Card>
|
||||
<div className={styles.header}>
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
返回
|
||||
</Button>
|
||||
<h2>模拟考试</h2>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setCreateModalVisible(true)}
|
||||
>
|
||||
创建试卷
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Spin spinning={loadingExams}>
|
||||
{exams.length === 0 ? (
|
||||
<Empty
|
||||
description="暂无试卷,点击上方按钮创建"
|
||||
style={{ marginTop: 40 }}
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.examGrid}>
|
||||
{exams.map((exam) => (
|
||||
<Card
|
||||
key={exam.id}
|
||||
className={styles.examCard}
|
||||
hoverable
|
||||
cover={
|
||||
<div className={styles.cardCover}>
|
||||
<div className={styles.coverIcon}>
|
||||
<FileTextOutlined />
|
||||
</div>
|
||||
<div className={styles.coverInfo}>
|
||||
<h3
|
||||
className={styles.examTitle}
|
||||
style={{ marginBottom: (exam.is_system || (exam.is_shared && exam.shared_by)) ? '12px' : '0' }}
|
||||
>
|
||||
{exam.title}
|
||||
</h3>
|
||||
{exam.is_system && (
|
||||
<Tag
|
||||
icon={<CrownOutlined />}
|
||||
color="orange"
|
||||
className={styles.shareTag}
|
||||
>
|
||||
系统
|
||||
</Tag>
|
||||
)}
|
||||
{exam.is_shared && exam.shared_by && (
|
||||
<Tag
|
||||
icon={<ShareAltOutlined />}
|
||||
color="purple"
|
||||
className={styles.shareTag}
|
||||
>
|
||||
来自 {exam.shared_by.nickname || exam.shared_by.username}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
actions={[
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={() => handleStartExam(exam.id, exam.has_in_progress_exam, exam.in_progress_record_id)}
|
||||
className={styles.actionButton}
|
||||
>
|
||||
{exam.has_in_progress_exam ? '继续' : '考试'}
|
||||
</Button>,
|
||||
// 只有非系统且自己创建的试卷才能分享
|
||||
!exam.is_system && !exam.is_shared && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ShareAltOutlined />}
|
||||
onClick={() => handleOpenShareModal(exam.id)}
|
||||
className={styles.actionButton}
|
||||
>
|
||||
分享
|
||||
</Button>
|
||||
),
|
||||
<Button
|
||||
type="text"
|
||||
icon={<HistoryOutlined />}
|
||||
onClick={() => handleViewRecords(exam.id)}
|
||||
className={styles.actionButton}
|
||||
>
|
||||
记录
|
||||
</Button>,
|
||||
<Button
|
||||
type="text"
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={() => navigate(`/exam/${exam.id}/answer`)}
|
||||
className={styles.actionButton}
|
||||
>
|
||||
答案
|
||||
</Button>,
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PrinterOutlined />}
|
||||
onClick={() => navigate(`/exam/${exam.id}/print`)}
|
||||
className={styles.actionButton}
|
||||
>
|
||||
打印
|
||||
</Button>,
|
||||
// 只有非系统且自己创建的试卷才能删除
|
||||
!exam.is_system && !exam.is_shared && (
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleDeleteExam(exam.id)}
|
||||
className={styles.actionButton}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
)
|
||||
].filter(Boolean)}
|
||||
>
|
||||
<div className={styles.cardContent}>
|
||||
|
||||
<div className={styles.examStats}>
|
||||
<div className={styles.statItem}>
|
||||
<Tag className={styles.valueTag}>
|
||||
<TrophyOutlined />
|
||||
<span>最高分 {exam.best_score || 0}</span>
|
||||
</Tag>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<Tag className={styles.valueTag}>
|
||||
<HistoryOutlined />
|
||||
<span>考试次数 {exam.attempt_count}</span>
|
||||
</Tag>
|
||||
</div>
|
||||
{exam.participant_count > 0 && (
|
||||
<div className={styles.statItem}>
|
||||
<Tag className={styles.valueTag}>
|
||||
<TeamOutlined />
|
||||
<span>{exam.participant_count} 人参与</span>
|
||||
</Tag>
|
||||
</div>
|
||||
)}
|
||||
{exam.has_in_progress_exam && (
|
||||
<div className={styles.statItem}>
|
||||
<Tag color="processing" className={styles.progressTag}>
|
||||
<PlayCircleOutlined />
|
||||
<span>进行中</span>
|
||||
</Tag>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Spin>
|
||||
</Card>
|
||||
|
||||
{/* 考试记录抽屉 */}
|
||||
<Drawer
|
||||
title="考试记录"
|
||||
placement="right"
|
||||
width={600}
|
||||
open={recordsDrawerVisible}
|
||||
onClose={() => setRecordsDrawerVisible(false)}
|
||||
>
|
||||
<Spin spinning={loadingRecords}>
|
||||
{examRecords.length === 0 ? (
|
||||
<Empty description="暂无考试记录" />
|
||||
) : (
|
||||
<List
|
||||
dataSource={examRecords}
|
||||
renderItem={(record: any) => (
|
||||
<Card
|
||||
key={record.id}
|
||||
style={{ marginBottom: 16 }}
|
||||
size="small"
|
||||
>
|
||||
{record.user && (
|
||||
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Avatar src={record.user.avatar} icon={<UserOutlined />} />
|
||||
<span style={{ fontWeight: 'bold' }}>
|
||||
{record.user.nickname || record.user.username}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Descriptions column={1} size="small">
|
||||
<Descriptions.Item label="状态">
|
||||
{record.status === 'in_progress' && <Tag color="processing">进行中</Tag>}
|
||||
{record.status === 'submitted' && <Tag color="warning">已提交</Tag>}
|
||||
{record.status === 'graded' && (
|
||||
<Tag color={record.is_passed ? 'success' : 'error'}>
|
||||
{record.is_passed ? '已通过' : '未通过'}
|
||||
</Tag>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="分数">
|
||||
{record.status === 'in_progress' ? (
|
||||
<span>-</span>
|
||||
) : (
|
||||
<span style={{
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: record.is_passed ? '#52c41a' : '#ff4d4f',
|
||||
lineHeight: 1,
|
||||
verticalAlign: 'middle'
|
||||
}}>
|
||||
{record.score} 分
|
||||
</span>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="开始时间">
|
||||
{record.start_time ? new Date(record.start_time).toLocaleString() : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="提交时间">
|
||||
{record.submit_time ? new Date(record.submit_time).toLocaleString() : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="用时">
|
||||
{record.time_spent ? `${Math.floor(record.time_spent / 60)} 分 ${record.time_spent % 60} 秒` : '-'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
<Space>
|
||||
{record.status === 'in_progress' && (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={() => {
|
||||
setRecordsDrawerVisible(false)
|
||||
navigate(`/exam/${record.exam_id}/taking/${record.id}`)
|
||||
}}
|
||||
>
|
||||
继续答题
|
||||
</Button>
|
||||
)}
|
||||
{record.status !== 'in_progress' && (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={() => handleViewRecordDetail(record.id)}
|
||||
>
|
||||
查看详情
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Spin>
|
||||
</Drawer>
|
||||
|
||||
{/* 创建试卷模态框 */}
|
||||
<Modal
|
||||
title="创建试卷"
|
||||
open={createModalVisible}
|
||||
onCancel={() => {
|
||||
setCreateModalVisible(false)
|
||||
form.resetFields()
|
||||
}}
|
||||
footer={null}
|
||||
width={500}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleCreateExam}
|
||||
>
|
||||
<Form.Item
|
||||
label="试卷标题"
|
||||
name="title"
|
||||
rules={[{ required: true, message: '请输入试卷标题' }]}
|
||||
>
|
||||
<Input placeholder="例如:保密知识测试卷(一)" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" loading={loading}>
|
||||
创建试卷
|
||||
</Button>
|
||||
<Button onClick={() => {
|
||||
setCreateModalVisible(false)
|
||||
form.resetFields()
|
||||
}}>
|
||||
取消
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 分享试卷模态框 */}
|
||||
<Modal
|
||||
title="分享试卷"
|
||||
open={shareModalVisible}
|
||||
onCancel={() => {
|
||||
setShareModalVisible(false)
|
||||
setSelectedUserIds([])
|
||||
setSharingExamId(null)
|
||||
}}
|
||||
onOk={handleShareExam}
|
||||
confirmLoading={sharingLoading}
|
||||
okText="确认分享"
|
||||
cancelText="取消"
|
||||
width={500}
|
||||
>
|
||||
<Spin spinning={loadingUsers}>
|
||||
<div style={{ marginBottom: 16, borderBottom: '1px solid #f0f0f0', paddingBottom: 12 }}>
|
||||
<Checkbox
|
||||
indeterminate={
|
||||
selectedUserIds.length > 0 && selectedUserIds.length < shareableUsers.length
|
||||
}
|
||||
checked={shareableUsers.length > 0 && selectedUserIds.length === shareableUsers.length}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
// 全选
|
||||
setSelectedUserIds(shareableUsers.map(user => user.id))
|
||||
} else {
|
||||
// 取消全选
|
||||
setSelectedUserIds([])
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 500 }}>
|
||||
全选 ({selectedUserIds.length}/{shareableUsers.length})
|
||||
</span>
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div style={{ maxHeight: 400, overflowY: 'auto' }}>
|
||||
{shareableUsers.length === 0 ? (
|
||||
<Empty description="暂无可分享的用户" />
|
||||
) : (
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{shareableUsers.map((user) => (
|
||||
<Card
|
||||
key={user.id}
|
||||
size="small"
|
||||
hoverable
|
||||
style={{
|
||||
backgroundColor: selectedUserIds.includes(user.id) ? '#f0f5ff' : undefined
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedUserIds.includes(user.id)}
|
||||
onChange={(e) => handleUserSelectionChange(user.id, e.target.checked)}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<Avatar
|
||||
size={40}
|
||||
src={user.avatar}
|
||||
icon={<UserOutlined />}
|
||||
/>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, fontSize: 14 }}>
|
||||
{user.nickname || user.username}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#999' }}>
|
||||
账号: {user.username}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Checkbox>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
{shareableUsers.length > 0 && (
|
||||
<div style={{ marginTop: 16, color: '#999', fontSize: 12 }}>
|
||||
已选择 {selectedUserIds.length} 个用户
|
||||
</div>
|
||||
)}
|
||||
</Spin>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExamManagement
|
||||
@ -1,391 +0,0 @@
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
background: #fafafa;
|
||||
padding: 0;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
// 固定顶栏
|
||||
.fixedTopBar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
background: rgba(250, 250, 250, 0.85);
|
||||
backdrop-filter: blur(40px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(40px) saturate(180%);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.topBarContent {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.backButton {
|
||||
color: #007aff;
|
||||
font-weight: 500;
|
||||
padding: 4px 12px;
|
||||
|
||||
&:hover {
|
||||
color: #0051d5;
|
||||
background: rgba(0, 122, 255, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
color: #1d1d1f !important;
|
||||
margin: 0 !important;
|
||||
font-weight: 700;
|
||||
font-size: 18px !important;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.content {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
padding-top: 80px;
|
||||
}
|
||||
|
||||
// 加载状态
|
||||
.loadingContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
// 考试说明卡片
|
||||
.examInfoCard {
|
||||
margin-bottom: 24px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
|
||||
:global(.ant-card-body) {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin-bottom: 16px;
|
||||
color: #1d1d1f;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
color: #6e6e73;
|
||||
line-height: 1.8;
|
||||
|
||||
li {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 题目组
|
||||
.questionGroup {
|
||||
margin-bottom: 32px;
|
||||
|
||||
:global(.ant-divider) {
|
||||
margin: 24px 0;
|
||||
border-color: #e5e5ea;
|
||||
}
|
||||
|
||||
h3 {
|
||||
color: #1d1d1f;
|
||||
font-weight: 600;
|
||||
font-size: 20px;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 题目卡片
|
||||
.questionCard {
|
||||
margin-bottom: 16px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
:global(.ant-card-body) {
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.questionHeader {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
span {
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: #1d1d1f;
|
||||
}
|
||||
}
|
||||
|
||||
// 论述题选择区域
|
||||
.essaySection {
|
||||
.essayChoiceCard {
|
||||
margin-bottom: 24px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||
|
||||
:global(.ant-card-body) {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin-bottom: 8px;
|
||||
color: #1d1d1f;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:global(.ant-typography) {
|
||||
color: #6e6e73;
|
||||
}
|
||||
}
|
||||
|
||||
.essayRadioGroup {
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
|
||||
:global(.ant-space) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.essayOptionCard {
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border: 2px solid transparent;
|
||||
|
||||
&:hover {
|
||||
border-color: #007aff;
|
||||
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.15);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: #007aff;
|
||||
background: rgba(0, 122, 255, 0.05);
|
||||
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.15);
|
||||
}
|
||||
|
||||
:global(.ant-card-body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
:global(.ant-radio) {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.essayContent {
|
||||
margin-top: 12px;
|
||||
margin-left: 24px;
|
||||
|
||||
:global(.ant-typography) {
|
||||
color: #1d1d1f;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 提交按钮区域
|
||||
.submitSection {
|
||||
margin-top: 48px;
|
||||
margin-bottom: 48px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.submitButton {
|
||||
min-width: 200px;
|
||||
height: 48px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.3);
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 6px 16px rgba(0, 122, 255, 0.4);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
// 表单项样式
|
||||
:global {
|
||||
.ant-form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ant-input,
|
||||
.ant-input-textarea {
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
border-color: #007aff;
|
||||
box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-radio-wrapper,
|
||||
.ant-checkbox-wrapper {
|
||||
font-size: 15px;
|
||||
line-height: 1.8;
|
||||
color: #1d1d1f;
|
||||
}
|
||||
|
||||
.ant-radio-checked .ant-radio-inner,
|
||||
.ant-checkbox-checked .ant-checkbox-inner {
|
||||
background-color: #007aff;
|
||||
border-color: #007aff;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计 - 移动端
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding-bottom: 70px;
|
||||
}
|
||||
|
||||
.topBarContent {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 0 12px;
|
||||
padding-top: 70px;
|
||||
}
|
||||
|
||||
.examInfoCard,
|
||||
.questionCard {
|
||||
border-radius: 8px;
|
||||
|
||||
:global(.ant-card-body) {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.questionHeader span {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.questionGroup {
|
||||
margin-bottom: 24px;
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.essaySection {
|
||||
.essayChoiceCard {
|
||||
:global(.ant-card-body) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.essayOptionCard {
|
||||
:global(.ant-card-body) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.essayContent {
|
||||
margin-left: 20px;
|
||||
|
||||
:global(.ant-typography) {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.submitSection {
|
||||
margin-top: 32px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.submitButton {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计 - 平板
|
||||
@media (min-width: 769px) and (max-width: 1024px) {
|
||||
.topBarContent {
|
||||
padding: 14px 24px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 0 24px;
|
||||
padding-top: 75px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px !important;
|
||||
}
|
||||
|
||||
.questionCard {
|
||||
:global(.ant-card-body) {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计 - PC端
|
||||
@media (min-width: 1025px) {
|
||||
.topBarContent {
|
||||
padding: 18px 32px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 0 32px;
|
||||
padding-top: 85px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 22px !important;
|
||||
}
|
||||
|
||||
.questionCard {
|
||||
:global(.ant-card-body) {
|
||||
padding: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.questionHeader span {
|
||||
font-size: 17px;
|
||||
}
|
||||
}
|
||||
@ -1,592 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Radio,
|
||||
Checkbox,
|
||||
Button,
|
||||
Typography,
|
||||
message,
|
||||
Spin,
|
||||
Space,
|
||||
Divider,
|
||||
Modal,
|
||||
} from 'antd'
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
CheckCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import * as examApi from '../api/exam'
|
||||
import type { Question } from '../types/question'
|
||||
import type { GetExamResponse, SubmitExamRequest } from '../types/exam'
|
||||
import styles from './ExamOnline.module.less'
|
||||
|
||||
const { Title, Paragraph, Text } = Typography
|
||||
const { TextArea } = Input
|
||||
|
||||
// 题型顺序映射
|
||||
const TYPE_ORDER: Record<string, number> = {
|
||||
'fill-in-blank': 1,
|
||||
'true-false': 2,
|
||||
'multiple-choice': 3,
|
||||
'multiple-selection': 4,
|
||||
'short-answer': 5,
|
||||
'ordinary-essay': 6,
|
||||
'management-essay': 6,
|
||||
}
|
||||
|
||||
// 题型名称映射
|
||||
const TYPE_NAME: Record<string, string> = {
|
||||
'fill-in-blank': '填空题',
|
||||
'true-false': '判断题',
|
||||
'multiple-choice': '单选题',
|
||||
'multiple-selection': '多选题',
|
||||
'short-answer': '简答题',
|
||||
'ordinary-essay': '论述题(普通涉密人员)',
|
||||
'management-essay': '论述题(保密管理人员)',
|
||||
}
|
||||
|
||||
const ExamOnline: React.FC = () => {
|
||||
const { examId } = useParams<{ examId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [examData, setExamData] = useState<GetExamResponse | null>(null)
|
||||
const [groupedQuestions, setGroupedQuestions] = useState<
|
||||
Record<string, Question[]>
|
||||
>({})
|
||||
const [essayChoice, setEssayChoice] = useState<'ordinary' | 'management' | null>(null)
|
||||
|
||||
// 加载考试详情
|
||||
useEffect(() => {
|
||||
if (!examId) {
|
||||
message.error('考试ID不存在')
|
||||
navigate('/exam/management')
|
||||
return
|
||||
}
|
||||
|
||||
const loadExam = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await examApi.getExam(Number(examId), false)
|
||||
if (res.success && res.data) {
|
||||
setExamData(res.data)
|
||||
// 按题型分组
|
||||
const grouped = groupQuestionsByType(res.data.questions)
|
||||
setGroupedQuestions(grouped)
|
||||
// 恢复答题进度
|
||||
loadProgress(res.data.questions)
|
||||
} else {
|
||||
message.error('加载考试失败')
|
||||
navigate('/exam/management')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.message || '加载考试失败')
|
||||
navigate('/exam/management')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadExam()
|
||||
}, [examId, navigate])
|
||||
|
||||
// 按题型分组题目
|
||||
const groupQuestionsByType = (questions: Question[]) => {
|
||||
const grouped: Record<string, Question[]> = {}
|
||||
questions.forEach((q) => {
|
||||
if (!grouped[q.type]) {
|
||||
grouped[q.type] = []
|
||||
}
|
||||
grouped[q.type].push(q)
|
||||
})
|
||||
return grouped
|
||||
}
|
||||
|
||||
// 保存答题进度到 localStorage
|
||||
const saveProgress = () => {
|
||||
if (!examId) return
|
||||
const values = form.getFieldsValue()
|
||||
const progress = {
|
||||
answers: values,
|
||||
essayChoice,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
localStorage.setItem(`exam_progress_${examId}`, JSON.stringify(progress))
|
||||
}
|
||||
|
||||
// 从 localStorage 恢复答题进度
|
||||
const loadProgress = (_questions: Question[]) => {
|
||||
if (!examId) return
|
||||
const saved = localStorage.getItem(`exam_progress_${examId}`)
|
||||
if (saved) {
|
||||
try {
|
||||
const progress = JSON.parse(saved)
|
||||
// 恢复表单值
|
||||
if (progress.answers) {
|
||||
form.setFieldsValue(progress.answers)
|
||||
}
|
||||
// 恢复论述题选择
|
||||
if (progress.essayChoice) {
|
||||
setEssayChoice(progress.essayChoice)
|
||||
}
|
||||
message.success('已恢复上次答题进度')
|
||||
} catch (e) {
|
||||
console.error('恢复进度失败', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 监听表单变化,自动保存进度
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
saveProgress()
|
||||
}, 5000) // 每5秒自动保存一次
|
||||
return () => clearInterval(timer)
|
||||
}, [examId, essayChoice])
|
||||
|
||||
// 提交考试
|
||||
const handleSubmit = async () => {
|
||||
// 验证论述题选择
|
||||
if (!essayChoice) {
|
||||
message.warning('请选择要作答的论述题')
|
||||
return
|
||||
}
|
||||
|
||||
// 验证表单
|
||||
try {
|
||||
await form.validateFields()
|
||||
} catch (error) {
|
||||
message.warning('请完成所有题目的作答')
|
||||
return
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: '确认提交',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: '提交后将无法修改答案,确定要提交吗?',
|
||||
okText: '确定提交',
|
||||
cancelText: '再检查一下',
|
||||
onOk: async () => {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const values = form.getFieldsValue()
|
||||
const answers: Record<string, any> = {}
|
||||
|
||||
// 转换答案格式
|
||||
Object.keys(values).forEach((key) => {
|
||||
const questionId = key.replace('question_', '')
|
||||
answers[questionId] = values[key]
|
||||
})
|
||||
|
||||
const submitData: SubmitExamRequest = {
|
||||
answers,
|
||||
essay_choice: essayChoice!,
|
||||
}
|
||||
|
||||
const res = await examApi.submitExam(Number(examId), submitData)
|
||||
if (res.success) {
|
||||
message.success('提交成功')
|
||||
// 清除进度
|
||||
localStorage.removeItem(`exam_progress_${examId}`)
|
||||
// 跳转到成绩页,传递提交结果
|
||||
navigate(`/exam/result/${res.data?.record_id}`, {
|
||||
state: { submitResult: res.data }
|
||||
})
|
||||
} else {
|
||||
message.error(res.message || '提交失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.message || '提交失败,请稍后重试')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 渲染填空题
|
||||
const renderFillInBlank = (question: Question, index: number) => {
|
||||
// 获取答案数组
|
||||
const answers = question.answer && Array.isArray(question.answer)
|
||||
? question.answer
|
||||
: question.answer
|
||||
? [String(question.answer)]
|
||||
: []
|
||||
|
||||
// 计算实际需要填空的数量
|
||||
const blankCount = question.content ? (question.content.match(/\*{4,}/g) || []).length : answers.length
|
||||
|
||||
// 处理题目内容,将 **** 替换为输入框占位符
|
||||
const renderQuestionContent = (content: string) => {
|
||||
if (!content) return content
|
||||
|
||||
let processedContent = content
|
||||
let inputIndex = 0
|
||||
|
||||
// 将所有的 **** 替换为输入框标识
|
||||
processedContent = processedContent.replace(/\*{4,}/g, () => {
|
||||
const id = `blank_${inputIndex}`
|
||||
inputIndex++
|
||||
return `[INPUT:${id}]`
|
||||
})
|
||||
|
||||
return processedContent
|
||||
}
|
||||
|
||||
// 渲染包含输入框的题目内容
|
||||
const renderContentWithInputs = () => {
|
||||
const processedContent = renderQuestionContent(question.content || '')
|
||||
const parts = processedContent.split(/\[INPUT:([^\]]+)\]/)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{parts.map((part, index) => {
|
||||
if (index % 2 === 1) {
|
||||
// 这是一个输入框标识符
|
||||
const inputIndex = Math.floor(index / 2)
|
||||
return (
|
||||
<Input
|
||||
key={`input_${inputIndex}`}
|
||||
style={{
|
||||
width: 120,
|
||||
margin: '0 4px',
|
||||
display: 'inline-block',
|
||||
verticalAlign: 'middle'
|
||||
}}
|
||||
placeholder={`第 ${inputIndex + 1} 空`}
|
||||
onChange={(e) => {
|
||||
const currentValue = form.getFieldValue(`question_${question.id}`) || []
|
||||
currentValue[inputIndex] = e.target.value
|
||||
form.setFieldValue(`question_${question.id}`, currentValue)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
// 这是普通文本
|
||||
return part
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={styles.questionCard} key={question.id}>
|
||||
<div className={styles.questionHeader}>
|
||||
<Text strong style={{ marginBottom: 12, display: 'block' }}>
|
||||
{index + 1}.
|
||||
</Text>
|
||||
<div style={{ marginBottom: 16, lineHeight: '1.8' }}>
|
||||
{renderContentWithInputs()}
|
||||
</div>
|
||||
</div>
|
||||
<Form.Item
|
||||
name={`question_${question.id}`}
|
||||
rules={[{ required: true, message: '请填写所有空格' }]}
|
||||
initialValue={Array(blankCount).fill('')}
|
||||
style={{ display: 'none' }} // 隐藏原来的表单项,因为我们用内联输入框了
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// 渲染判断题
|
||||
const renderTrueFalse = (question: Question, index: number) => {
|
||||
return (
|
||||
<Card className={styles.questionCard} key={question.id}>
|
||||
<div className={styles.questionHeader}>
|
||||
<Text strong>
|
||||
{index + 1}. {question.content}
|
||||
</Text>
|
||||
</div>
|
||||
<Form.Item
|
||||
name={`question_${question.id}`}
|
||||
rules={[{ required: true, message: '请选择答案' }]}
|
||||
>
|
||||
<Radio.Group>
|
||||
<Space direction="vertical">
|
||||
<Radio value="true">正确</Radio>
|
||||
<Radio value="false">错误</Radio>
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// 渲染单选题
|
||||
const renderMultipleChoice = (question: Question, index: number) => {
|
||||
return (
|
||||
<Card className={styles.questionCard} key={question.id}>
|
||||
<div className={styles.questionHeader}>
|
||||
<Text strong>
|
||||
{index + 1}. {question.content}
|
||||
</Text>
|
||||
</div>
|
||||
<Form.Item
|
||||
name={`question_${question.id}`}
|
||||
rules={[{ required: true, message: '请选择答案' }]}
|
||||
>
|
||||
<Radio.Group>
|
||||
<Space direction="vertical">
|
||||
{(question.options || []).map((opt) => (
|
||||
<Radio key={opt.key} value={opt.key}>
|
||||
{opt.key}. {opt.value}
|
||||
</Radio>
|
||||
))}
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// 渲染多选题
|
||||
const renderMultipleSelection = (question: Question, index: number) => {
|
||||
return (
|
||||
<Card className={styles.questionCard} key={question.id}>
|
||||
<div className={styles.questionHeader}>
|
||||
<Text strong>
|
||||
{index + 1}. {question.content}
|
||||
</Text>
|
||||
</div>
|
||||
<Form.Item
|
||||
name={`question_${question.id}`}
|
||||
rules={[{ required: true, message: '请选择答案' }]}
|
||||
>
|
||||
<Checkbox.Group>
|
||||
<Space direction="vertical">
|
||||
{(question.options || []).map((opt) => (
|
||||
<Checkbox key={opt.key} value={opt.key}>
|
||||
{opt.key}. {opt.value}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Space>
|
||||
</Checkbox.Group>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// 渲染简答题
|
||||
const renderShortAnswer = (question: Question, index: number) => {
|
||||
return (
|
||||
<Card className={styles.questionCard} key={question.id}>
|
||||
<div className={styles.questionHeader}>
|
||||
<Text strong>
|
||||
{index + 1}. {question.content}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: '13px', marginLeft: '8px' }}>
|
||||
(仅供参考,不计分)
|
||||
</Text>
|
||||
</div>
|
||||
<Form.Item name={`question_${question.id}`}>
|
||||
<TextArea rows={4} placeholder="请输入答案" />
|
||||
</Form.Item>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// 渲染论述题
|
||||
const renderEssay = (questions: Question[]) => {
|
||||
const ordinaryEssay = questions.find((q) => q.type === 'ordinary-essay')
|
||||
const managementEssay = questions.find((q) => q.type === 'management-essay')
|
||||
|
||||
if (!ordinaryEssay || !managementEssay) return null
|
||||
|
||||
return (
|
||||
<div className={styles.essaySection}>
|
||||
<Card className={styles.essayChoiceCard}>
|
||||
<Title level={4}>请选择要作答的论述题(二选一)</Title>
|
||||
<Paragraph type="secondary">
|
||||
以下提供两道论述题,请选择其中一道进行作答
|
||||
</Paragraph>
|
||||
<Radio.Group
|
||||
value={essayChoice}
|
||||
onChange={(e) => setEssayChoice(e.target.value)}
|
||||
className={styles.essayRadioGroup}
|
||||
>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<Card
|
||||
className={`${styles.essayOptionCard} ${
|
||||
essayChoice === 'ordinary' ? styles.selected : ''
|
||||
}`}
|
||||
>
|
||||
<Radio value="ordinary">
|
||||
<Text strong>普通涉密人员论述题</Text>
|
||||
</Radio>
|
||||
<div className={styles.essayContent}>
|
||||
<Paragraph>{ordinaryEssay.content}</Paragraph>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className={`${styles.essayOptionCard} ${
|
||||
essayChoice === 'management' ? styles.selected : ''
|
||||
}`}
|
||||
>
|
||||
<Radio value="management">
|
||||
<Text strong>保密管理人员论述题</Text>
|
||||
</Radio>
|
||||
<div className={styles.essayContent}>
|
||||
<Paragraph>{managementEssay.content}</Paragraph>
|
||||
</div>
|
||||
</Card>
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
</Card>
|
||||
|
||||
{essayChoice && (
|
||||
<Card className={styles.questionCard}>
|
||||
<div className={styles.questionHeader}>
|
||||
<Text strong>
|
||||
{essayChoice === 'ordinary'
|
||||
? '普通涉密人员论述题'
|
||||
: '保密管理人员论述题'}
|
||||
</Text>
|
||||
</div>
|
||||
<Paragraph>{essayChoice === 'ordinary' ? ordinaryEssay.content : managementEssay.content}</Paragraph>
|
||||
<Form.Item
|
||||
name={`question_${essayChoice === 'ordinary' ? ordinaryEssay.id : managementEssay.id}`}
|
||||
rules={[{ required: true, message: '请完成论述题作答' }]}
|
||||
>
|
||||
<TextArea rows={8} placeholder="请输入您的答案(建议300字以上)" showCount />
|
||||
</Form.Item>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 渲染题目组
|
||||
const renderQuestionGroup = (type: string, questions: Question[]) => {
|
||||
let startIndex = 0
|
||||
// 计算该题型的起始序号
|
||||
Object.keys(groupedQuestions)
|
||||
.filter((t) => TYPE_ORDER[t] < TYPE_ORDER[type])
|
||||
.forEach((t) => {
|
||||
startIndex += groupedQuestions[t].length
|
||||
})
|
||||
|
||||
return (
|
||||
<div key={type} className={styles.questionGroup}>
|
||||
<Divider orientation="left">
|
||||
<Title level={3}>{TYPE_NAME[type]}</Title>
|
||||
</Divider>
|
||||
{questions.map((question, index) => {
|
||||
switch (type) {
|
||||
case 'fill-in-blank':
|
||||
return renderFillInBlank(question, startIndex + index)
|
||||
case 'true-false':
|
||||
return renderTrueFalse(question, startIndex + index)
|
||||
case 'multiple-choice':
|
||||
return renderMultipleChoice(question, startIndex + index)
|
||||
case 'multiple-selection':
|
||||
return renderMultipleSelection(question, startIndex + index)
|
||||
case 'short-answer':
|
||||
return renderShortAnswer(question, startIndex + index)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.loadingContainer}>
|
||||
<Spin size="large" />
|
||||
<Text style={{ marginTop: 16 }}>加载考试中...</Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!examData) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 获取论述题
|
||||
const essayQuestions = [
|
||||
...(groupedQuestions['ordinary-essay'] || []),
|
||||
...(groupedQuestions['management-essay'] || []),
|
||||
]
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* 固定顶栏 */}
|
||||
<div className={styles.fixedTopBar}>
|
||||
<div className={styles.topBarContent}>
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/exam/management')}
|
||||
className={styles.backButton}
|
||||
type="text"
|
||||
>
|
||||
返回
|
||||
</Button>
|
||||
<Title level={3} className={styles.title}>
|
||||
在线答题
|
||||
</Title>
|
||||
<div style={{ width: 80 }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主内容区 */}
|
||||
<div className={styles.content}>
|
||||
<Card className={styles.examInfoCard}>
|
||||
<Title level={4}>考试说明</Title>
|
||||
<ul>
|
||||
<li>请仔细阅读每道题目,认真作答</li>
|
||||
<li>论述题需要从两道题中选择一道作答</li>
|
||||
<li>简答题仅供参考,不计入总分</li>
|
||||
<li>答题进度会自动保存,可以放心刷新页面</li>
|
||||
<li>提交后将无法修改答案,请仔细检查后再提交</li>
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<Form form={form} layout="vertical">
|
||||
{/* 按题型顺序渲染题目 */}
|
||||
{Object.keys(groupedQuestions)
|
||||
.filter((type) => type !== 'ordinary-essay' && type !== 'management-essay')
|
||||
.sort((a, b) => TYPE_ORDER[a] - TYPE_ORDER[b])
|
||||
.map((type) => renderQuestionGroup(type, groupedQuestions[type]))}
|
||||
|
||||
{/* 渲染论述题(二选一) */}
|
||||
{essayQuestions.length > 0 && (
|
||||
<div className={styles.questionGroup}>
|
||||
<Divider orientation="left">
|
||||
<Title level={3}>论述题(二选一)</Title>
|
||||
</Divider>
|
||||
{renderEssay(essayQuestions)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 提交按钮 */}
|
||||
<div className={styles.submitSection}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<CheckCircleOutlined />}
|
||||
onClick={handleSubmit}
|
||||
loading={submitting}
|
||||
className={styles.submitButton}
|
||||
>
|
||||
提交考试
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExamOnline
|
||||
@ -1,580 +0,0 @@
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
// 操作按钮区(打印时隐藏)
|
||||
.actionBar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
background: #fff;
|
||||
padding: 16px 0;
|
||||
margin-bottom: 24px;
|
||||
border-bottom: 1px solid #e5e5ea;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.backButton {
|
||||
color: #007aff;
|
||||
border-color: #007aff;
|
||||
|
||||
&:hover {
|
||||
color: #0051d5;
|
||||
border-color: #0051d5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 加载状态
|
||||
.loadingContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
// 打印内容区
|
||||
.printContent {
|
||||
max-width: 210mm; // A4纸宽度
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
// 试卷头部
|
||||
.paperHeader {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 2px solid #1d1d1f;
|
||||
|
||||
.paperTitle {
|
||||
margin: 0 0 16px 0 !important;
|
||||
color: #1d1d1f !important;
|
||||
font-weight: 700 !important;
|
||||
font-size: 28px !important;
|
||||
}
|
||||
|
||||
.examInfo {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
margin-top: 16px;
|
||||
|
||||
.infoItem {
|
||||
font-size: 16px;
|
||||
color: #1d1d1f;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 考试说明卡片
|
||||
.instructionCard {
|
||||
margin-bottom: 24px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #d1d1d6;
|
||||
|
||||
:global(.ant-card-body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin-bottom: 12px;
|
||||
color: #1d1d1f;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
color: #1d1d1f;
|
||||
line-height: 1.8;
|
||||
|
||||
li {
|
||||
margin-bottom: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 题目组
|
||||
.questionGroup {
|
||||
margin-bottom: 32px;
|
||||
page-break-inside: avoid;
|
||||
|
||||
.groupHeader {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #d1d1d6;
|
||||
|
||||
.groupTitle {
|
||||
font-size: 18px;
|
||||
color: #1d1d1f;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.groupScore {
|
||||
font-size: 14px;
|
||||
margin-left: 8px;
|
||||
color: #6e6e73;
|
||||
}
|
||||
}
|
||||
|
||||
.questionsList {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 题目项
|
||||
.questionItem {
|
||||
margin-bottom: 24px;
|
||||
page-break-inside: avoid;
|
||||
|
||||
.questionContent {
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.8;
|
||||
|
||||
span {
|
||||
font-size: 15px;
|
||||
color: #1d1d1f;
|
||||
}
|
||||
}
|
||||
|
||||
.optionsList {
|
||||
margin: 12px 0;
|
||||
padding-left: 20px;
|
||||
|
||||
.optionItem {
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.6;
|
||||
font-size: 14px;
|
||||
color: #1d1d1f;
|
||||
}
|
||||
}
|
||||
|
||||
.answerArea {
|
||||
margin-top: 12px;
|
||||
padding: 8px 0;
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
color: #1d1d1f;
|
||||
}
|
||||
|
||||
.blankLine {
|
||||
margin-bottom: 8px;
|
||||
line-height: 2;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.answerLines {
|
||||
margin-top: 8px;
|
||||
|
||||
.answerLine {
|
||||
line-height: 2;
|
||||
margin-bottom: 4px;
|
||||
font-size: 14px;
|
||||
color: #1d1d1f;
|
||||
}
|
||||
}
|
||||
|
||||
.essayAnswer {
|
||||
:global(.ant-typography) {
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.8;
|
||||
font-size: 14px;
|
||||
color: #1d1d1f;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 打印样式
|
||||
@media print {
|
||||
// 隐藏不需要打印的元素
|
||||
.noPrint,
|
||||
:global(.noPrint) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
// A4纸张设置
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 1cm;
|
||||
}
|
||||
|
||||
.container {
|
||||
background: #fff;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.printContent {
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.paperHeader {
|
||||
margin-bottom: 6px;
|
||||
padding-bottom: 4px;
|
||||
// 防止试卷标题和考试说明分页
|
||||
page-break-after: avoid;
|
||||
page-break-inside: avoid;
|
||||
|
||||
.paperTitle {
|
||||
font-size: 16pt !important;
|
||||
margin-bottom: 4px !important;
|
||||
}
|
||||
|
||||
.examInfo {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
width: 100%;
|
||||
font-family: 'SimSun', '宋体', serif;
|
||||
|
||||
.infoItem {
|
||||
font-size: 9pt;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.instructionCard {
|
||||
margin-bottom: 6px;
|
||||
border: 1px solid #000;
|
||||
// 确保圆角不被裁剪
|
||||
border-radius: 8px;
|
||||
overflow: visible;
|
||||
// 添加一些内边距确保圆角有空间显示
|
||||
padding: 2px;
|
||||
// 防止考试说明和填空题分页
|
||||
page-break-after: avoid;
|
||||
page-break-inside: avoid;
|
||||
|
||||
:global(.ant-card-body) {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 10pt;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 12px;
|
||||
|
||||
li {
|
||||
font-size: 8pt;
|
||||
margin-bottom: 1px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.questionGroup {
|
||||
margin-bottom: 8px;
|
||||
// 防止题型组内部分页
|
||||
page-break-inside: avoid;
|
||||
// 尽量让下一个题型紧接着显示
|
||||
page-break-after: avoid;
|
||||
|
||||
.groupHeader {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
margin-bottom: 4px;
|
||||
padding-bottom: 2px;
|
||||
// 确保题型标题和第一道题在同一页
|
||||
page-break-after: avoid;
|
||||
|
||||
.groupTitle {
|
||||
font-size: 12pt;
|
||||
}
|
||||
|
||||
.groupScore {
|
||||
font-size: 10pt;
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.questionItem {
|
||||
margin-bottom: 6px;
|
||||
// 防止题目内部分页,保持题目完整性
|
||||
page-break-inside: avoid;
|
||||
|
||||
.questionContent {
|
||||
margin-bottom: 3px;
|
||||
line-height: 1.3;
|
||||
|
||||
span {
|
||||
font-size: 10pt;
|
||||
font-family: 'SimSun', '宋体', serif !important;
|
||||
font-weight: normal !important;
|
||||
}
|
||||
}
|
||||
|
||||
.optionsList {
|
||||
margin: 4px 0;
|
||||
padding-left: 14px;
|
||||
|
||||
.optionItem {
|
||||
margin-bottom: 1px;
|
||||
font-size: 9pt;
|
||||
font-family: 'SimSun', '宋体', serif !important;
|
||||
font-weight: normal !important;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
.answerArea {
|
||||
margin-top: 4px;
|
||||
|
||||
span {
|
||||
font-size: 9pt;
|
||||
font-family: 'SimSun', '宋体', serif !important;
|
||||
font-weight: normal !important;
|
||||
}
|
||||
|
||||
.blankLine {
|
||||
font-size: 11pt;
|
||||
font-family: 'SimSun', '宋体', serif !important;
|
||||
font-weight: normal !important;
|
||||
}
|
||||
|
||||
.answerLines {
|
||||
.answerLine {
|
||||
font-size: 11pt;
|
||||
font-family: 'SimSun', '宋体', serif !important;
|
||||
font-weight: normal !important;
|
||||
}
|
||||
}
|
||||
|
||||
.essayAnswer {
|
||||
:global(.ant-typography) {
|
||||
font-size: 11pt;
|
||||
font-family: 'SimSun', '宋体', serif !important;
|
||||
font-weight: normal !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 强制分页
|
||||
.pageBreak {
|
||||
page-break-after: always;
|
||||
}
|
||||
|
||||
// 移除分页限制,允许更紧密的排版
|
||||
|
||||
// 黑白打印优化和字体设置
|
||||
* {
|
||||
color: #000 !important;
|
||||
background: #fff !important;
|
||||
box-shadow: none !important;
|
||||
text-shadow: none !important;
|
||||
font-family: 'SimSun', '宋体', serif !important;
|
||||
font-weight: normal !important;
|
||||
}
|
||||
|
||||
// 保留边框
|
||||
.paperHeader,
|
||||
.instructionCard,
|
||||
.groupHeader {
|
||||
border-color: #000 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计 - 移动端
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.actionBar {
|
||||
padding: 12px 0;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
:global(.ant-space) {
|
||||
width: auto;
|
||||
|
||||
button {
|
||||
flex: none;
|
||||
width: auto;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.printContent {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.paperHeader {
|
||||
.paperTitle {
|
||||
font-size: 22px !important;
|
||||
}
|
||||
|
||||
.examInfo {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.infoItem {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.instructionCard {
|
||||
:global(.ant-card-body) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
ul {
|
||||
li {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.questionGroup {
|
||||
.groupHeader {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
|
||||
.groupTitle {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.groupScore {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.questionItem {
|
||||
.questionContent {
|
||||
span {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.optionsList {
|
||||
padding-left: 16px;
|
||||
|
||||
.optionItem {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.answerArea {
|
||||
span {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.blankLine {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.answerLines {
|
||||
.answerLine {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.essayAnswer {
|
||||
:global(.ant-typography) {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计 - 平板
|
||||
@media (min-width: 769px) and (max-width: 1024px) {
|
||||
.container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.printContent {
|
||||
max-width: 190mm;
|
||||
}
|
||||
|
||||
.paperHeader {
|
||||
.paperTitle {
|
||||
font-size: 26px !important;
|
||||
}
|
||||
|
||||
.examInfo {
|
||||
.infoItem {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.questionGroup {
|
||||
.groupHeader {
|
||||
.groupTitle {
|
||||
font-size: 17px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.questionItem {
|
||||
.questionContent {
|
||||
span {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计 - PC端
|
||||
@media (min-width: 1025px) {
|
||||
.container {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.printContent {
|
||||
max-width: 210mm;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.actionBar {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.paperHeader {
|
||||
.paperTitle {
|
||||
font-size: 28px !important;
|
||||
}
|
||||
|
||||
.examInfo {
|
||||
.infoItem {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,484 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { Card, Button, Typography, message, Spin } from 'antd'
|
||||
import { ArrowLeftOutlined, FileTextOutlined } from '@ant-design/icons'
|
||||
import * as examApi from '../api/exam'
|
||||
import type { Question } from '../types/question'
|
||||
import type { GetExamResponse } from '../types/exam'
|
||||
import styles from './ExamPrint.module.less'
|
||||
|
||||
const { Title, Paragraph, Text } = Typography
|
||||
|
||||
// 日期格式化函数
|
||||
const formatDate = (date: Date): string => {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
// 题型顺序映射
|
||||
const TYPE_ORDER: Record<string, number> = {
|
||||
'fill-in-blank': 1,
|
||||
'true-false': 2,
|
||||
'multiple-choice': 3,
|
||||
'multiple-selection': 4,
|
||||
'short-answer': 5,
|
||||
'ordinary-essay': 6,
|
||||
'management-essay': 6,
|
||||
}
|
||||
|
||||
// 题型名称映射
|
||||
const TYPE_NAME: Record<string, string> = {
|
||||
'fill-in-blank': '一、填空题',
|
||||
'true-false': '二、判断题',
|
||||
'multiple-choice': '三、单选题',
|
||||
'multiple-selection': '四、多选题',
|
||||
'short-answer': '五、简答题',
|
||||
'ordinary-essay': '六、论述题',
|
||||
'management-essay': '六、论述题',
|
||||
}
|
||||
|
||||
// 题型分值映射
|
||||
const TYPE_SCORE: Record<string, number> = {
|
||||
'fill-in-blank': 2.0,
|
||||
'true-false': 1.0,
|
||||
'multiple-choice': 1.0,
|
||||
'multiple-selection': 2.0,
|
||||
'short-answer': 0, // 不计分
|
||||
'ordinary-essay': 10.0,
|
||||
'management-essay': 10.0,
|
||||
}
|
||||
|
||||
const ExamPrint: React.FC = () => {
|
||||
const { examId } = useParams<{ examId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const showAnswer = searchParams.get('show_answer') === 'true'
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [examData, setExamData] = useState<GetExamResponse | null>(null)
|
||||
const [groupedQuestions, setGroupedQuestions] = useState<Record<string, Question[]>>({})
|
||||
|
||||
// 加载考试详情
|
||||
useEffect(() => {
|
||||
if (!examId) {
|
||||
message.error('考试ID不存在')
|
||||
navigate('/exam/management')
|
||||
return
|
||||
}
|
||||
|
||||
const loadExam = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await examApi.getExam(Number(examId), showAnswer)
|
||||
if (res.success && res.data) {
|
||||
setExamData(res.data)
|
||||
// 按题型分组
|
||||
const grouped = groupQuestionsByType(res.data.questions)
|
||||
setGroupedQuestions(grouped)
|
||||
} else {
|
||||
message.error('加载考试失败')
|
||||
navigate('/exam/management')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.message || '加载考试失败')
|
||||
navigate('/exam/management')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadExam()
|
||||
}, [examId, showAnswer, navigate])
|
||||
|
||||
// 按题型分组题目
|
||||
const groupQuestionsByType = (questions: Question[]) => {
|
||||
const grouped: Record<string, Question[]> = {}
|
||||
questions.forEach((q) => {
|
||||
if (!grouped[q.type]) {
|
||||
grouped[q.type] = []
|
||||
}
|
||||
grouped[q.type].push(q)
|
||||
})
|
||||
return grouped
|
||||
}
|
||||
|
||||
// 打印试卷
|
||||
const handlePrint = () => {
|
||||
window.print()
|
||||
}
|
||||
|
||||
// 格式化答案显示
|
||||
const formatAnswer = (question: Question): string => {
|
||||
if (!question.answer) return ''
|
||||
|
||||
switch (question.type) {
|
||||
case 'fill-in-blank':
|
||||
if (Array.isArray(question.answer)) {
|
||||
return question.answer.join('、')
|
||||
}
|
||||
return String(question.answer)
|
||||
|
||||
case 'true-false':
|
||||
return question.answer === 'true' || question.answer === true ? '正确' : '错误'
|
||||
|
||||
case 'multiple-choice':
|
||||
return String(question.answer)
|
||||
|
||||
case 'multiple-selection':
|
||||
if (Array.isArray(question.answer)) {
|
||||
return question.answer.sort().join('')
|
||||
}
|
||||
return String(question.answer)
|
||||
|
||||
case 'short-answer':
|
||||
case 'ordinary-essay':
|
||||
case 'management-essay':
|
||||
return String(question.answer)
|
||||
|
||||
default:
|
||||
return String(question.answer)
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染填空题
|
||||
const renderFillInBlank = (question: Question, index: number) => {
|
||||
// 获取答案数组
|
||||
const answers = question.answer && Array.isArray(question.answer)
|
||||
? question.answer
|
||||
: question.answer
|
||||
? [String(question.answer)]
|
||||
: []
|
||||
|
||||
// 计算下划线字符数量
|
||||
const calculateUnderscoreCount = (blankIndex: number, totalBlanks: number) => {
|
||||
// 优先使用 answer_lengths 字段(在 show_answer=false 时也会返回)
|
||||
if (question.answer_lengths && question.answer_lengths[blankIndex] !== undefined) {
|
||||
const answerLength = question.answer_lengths[blankIndex]
|
||||
// 最少8个下划线字符
|
||||
return Math.max(answerLength, 8)
|
||||
}
|
||||
|
||||
// 如果有实际的答案数据,使用答案长度
|
||||
if (answers[blankIndex]) {
|
||||
const answerText = String(answers[blankIndex])
|
||||
// 最少8个下划线字符
|
||||
return Math.max(answerText.length, 8)
|
||||
}
|
||||
|
||||
// 如果没有任何答案数据,使用默认策略
|
||||
if (totalBlanks === 1) {
|
||||
return 8 // 单个填空:8个下划线字符
|
||||
} else {
|
||||
// 多个填空:8-12个下划线字符循环
|
||||
const counts = [8, 10, 12, 9, 11]
|
||||
return counts[blankIndex % counts.length]
|
||||
}
|
||||
}
|
||||
|
||||
// 处理题目内容,将 **** 替换为下划线字符
|
||||
const renderQuestionContent = (content: string) => {
|
||||
if (!content) return content
|
||||
|
||||
let processedContent = content
|
||||
let blankIndex = 0
|
||||
|
||||
// 先计算出总共有多少个填空
|
||||
const totalBlanks = (content.match(/\*{4,}/g) || []).length
|
||||
|
||||
// 将所有的 **** 替换为下划线字符
|
||||
processedContent = processedContent.replace(/\*{4,}/g, () => {
|
||||
const underscoreCount = calculateUnderscoreCount(blankIndex, totalBlanks)
|
||||
blankIndex++
|
||||
return '_'.repeat(underscoreCount)
|
||||
})
|
||||
|
||||
return processedContent
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={question.id} className={styles.questionItem}>
|
||||
<div className={styles.questionContent}>
|
||||
<Text>
|
||||
{index + 1}.{' '}
|
||||
</Text>
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderQuestionContent(question.content || '')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{showAnswer && answers.length > 0 && (
|
||||
<div className={styles.answerArea}>
|
||||
<Text>参考答案:{formatAnswer(question)}</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 渲染判断题
|
||||
const renderTrueFalse = (question: Question, index: number) => {
|
||||
return (
|
||||
<div key={question.id} className={styles.questionItem}>
|
||||
<div className={styles.questionContent}>
|
||||
<Text>
|
||||
{index + 1}. {question.content}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 渲染单选题
|
||||
const renderMultipleChoice = (question: Question, index: number) => {
|
||||
return (
|
||||
<div key={question.id} className={styles.questionItem}>
|
||||
<div className={styles.questionContent}>
|
||||
<Text>
|
||||
{index + 1}. {question.content}
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.optionsList}>
|
||||
{(question.options || []).map((opt) => (
|
||||
<div key={opt.key} className={styles.optionItem}>
|
||||
{opt.key}. {opt.value}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 渲染多选题
|
||||
const renderMultipleSelection = (question: Question, index: number) => {
|
||||
return (
|
||||
<div key={question.id} className={styles.questionItem}>
|
||||
<div className={styles.questionContent}>
|
||||
<Text>
|
||||
{index + 1}. {question.content}
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.optionsList}>
|
||||
{(question.options || []).map((opt) => (
|
||||
<div key={opt.key} className={styles.optionItem}>
|
||||
{opt.key}. {opt.value}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 渲染简答题
|
||||
const renderShortAnswer = (question: Question, index: number) => {
|
||||
return (
|
||||
<div key={question.id} className={styles.questionItem}>
|
||||
<div className={styles.questionContent}>
|
||||
<Text>
|
||||
{index + 1}. {question.content}
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.answerArea}>
|
||||
{showAnswer ? (
|
||||
<div className={styles.essayAnswer}>
|
||||
<Text>参考答案:</Text>
|
||||
<Paragraph>{formatAnswer(question)}</Paragraph>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.answerLines}>
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className={styles.answerLine} style={{ height: '30px' }}>
|
||||
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 渲染论述题
|
||||
const renderEssay = (question: Question, index: number) => {
|
||||
const getUserTypeHint = () => {
|
||||
if (question.type === 'ordinary-essay') {
|
||||
return '(普通涉密人员作答)'
|
||||
} else if (question.type === 'management-essay') {
|
||||
return '(保密管理人员作答)'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={question.id} className={styles.questionItem}>
|
||||
<div className={styles.questionContent}>
|
||||
<Text>
|
||||
{index + 1}. {question.content}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: '12px', marginLeft: '8px' }}>
|
||||
{getUserTypeHint()}
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.answerArea}>
|
||||
{showAnswer ? (
|
||||
<div className={styles.essayAnswer}>
|
||||
<Text>参考答案:</Text>
|
||||
<Paragraph>{formatAnswer(question)}</Paragraph>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.answerLines}>
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<div key={i} className={styles.answerLine} style={{ height: '35px' }}>
|
||||
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 渲染题目组
|
||||
const renderQuestionGroup = (type: string, questions: Question[]) => {
|
||||
// 每个题型分类都从1开始编号
|
||||
const startIndex = 0
|
||||
|
||||
// 计算该题型总分
|
||||
const totalScore = questions.length * TYPE_SCORE[type]
|
||||
|
||||
return (
|
||||
<div key={type} className={styles.questionGroup}>
|
||||
<div className={styles.groupHeader}>
|
||||
<span className={styles.groupTitle}>
|
||||
{TYPE_NAME[type]}
|
||||
{TYPE_SCORE[type] > 0 && (
|
||||
<span className={styles.groupScore} style={{ marginLeft: '8px' }}>
|
||||
(共{questions.length}题,每题{TYPE_SCORE[type]}分,共{totalScore}分)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.questionsList}>
|
||||
{questions.map((question, index) => {
|
||||
switch (type) {
|
||||
case 'fill-in-blank':
|
||||
return renderFillInBlank(question, startIndex + index)
|
||||
case 'true-false':
|
||||
return renderTrueFalse(question, startIndex + index)
|
||||
case 'multiple-choice':
|
||||
return renderMultipleChoice(question, startIndex + index)
|
||||
case 'multiple-selection':
|
||||
return renderMultipleSelection(question, startIndex + index)
|
||||
case 'short-answer':
|
||||
return renderShortAnswer(question, startIndex + index)
|
||||
case 'ordinary-essay':
|
||||
case 'management-essay':
|
||||
return renderEssay(question, startIndex + index)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.loadingContainer}>
|
||||
<Spin size="large" />
|
||||
<Text style={{ marginTop: 16 }}>加载考试中...</Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!examData) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 获取论述题(合并普通和管理两类)
|
||||
const essayQuestions = [
|
||||
...(groupedQuestions['ordinary-essay'] || []),
|
||||
...(groupedQuestions['management-essay'] || []),
|
||||
]
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* 操作按钮区 - 打印时隐藏 */}
|
||||
<div className={`${styles.actionBar} noPrint`}>
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/exam/management')}
|
||||
className={styles.backButton}
|
||||
>
|
||||
返回
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={handlePrint}
|
||||
>
|
||||
打印试卷
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 打印内容区 */}
|
||||
<div className={styles.printContent}>
|
||||
{/* 试卷头部 */}
|
||||
<div className={styles.paperHeader}>
|
||||
<Title level={2} className={styles.paperTitle}>
|
||||
保密知识模拟考试{showAnswer ? '(答案)' : ''}
|
||||
</Title>
|
||||
<div style={{ fontFamily: 'SimSun, 宋体, serif', fontSize: '9pt' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', width: '100%' }}>
|
||||
<span style={{ flex: '0 0 auto', textAlign: 'left' }}>日期:{formatDate(new Date())}</span>
|
||||
<span style={{ flex: '0 0 auto', textAlign: 'center' }}>姓名:________________</span>
|
||||
<span style={{ flex: '0 0 auto', textAlign: 'center' }}>职位:________________</span>
|
||||
<span style={{ flex: '0 0 auto', textAlign: 'right' }}>成绩:________________</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 考试说明 */}
|
||||
{!showAnswer && (
|
||||
<Card className={styles.instructionCard}>
|
||||
<Title level={4}>考试说明</Title>
|
||||
<ul>
|
||||
<li>本试卷满分100分,考试时间为60分钟</li>
|
||||
<li>简答题请在答题区域内作答,字迹清晰工整</li>
|
||||
<li>论述题根据自己的职务类型从以下2道题目中选答1道</li>
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 按题型渲染题目 */}
|
||||
{Object.keys(groupedQuestions)
|
||||
.filter((type) => type !== 'ordinary-essay' && type !== 'management-essay')
|
||||
.sort((a, b) => TYPE_ORDER[a] - TYPE_ORDER[b])
|
||||
.map((type) => renderQuestionGroup(type, groupedQuestions[type]))}
|
||||
|
||||
{/* 论述题部分 */}
|
||||
{essayQuestions.length > 0 && (
|
||||
<div className={styles.questionGroup}>
|
||||
<div className={styles.groupHeader}>
|
||||
<span className={styles.groupTitle}>
|
||||
{TYPE_NAME['ordinary-essay']}
|
||||
<span className={styles.groupScore} style={{ marginLeft: '8px' }}>
|
||||
(根据自己的职务,在以下2道论述题选择1道作答,共10分)
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.questionsList}>
|
||||
{essayQuestions.map((question, index) => renderEssay(question, index))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExamPrint
|
||||
@ -1,221 +0,0 @@
|
||||
.loadingContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
|
||||
:global(.ant-result) {
|
||||
padding: 40px 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.statsCard {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.passMark {
|
||||
margin-top: 24px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.typeScoreCard {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.typeScoreItem {
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: #f0f5ff;
|
||||
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.15);
|
||||
}
|
||||
|
||||
.typeScoreHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.typeScoreContent {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.typeScoreProgress {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: #e8e8e8;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
|
||||
.typeScoreBar {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.6s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.detailCard {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.panelHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.questionItem {
|
||||
.questionNumber {
|
||||
display: inline-block;
|
||||
background: #1890ff;
|
||||
color: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.answerDetail {
|
||||
.questionContent {
|
||||
padding: 12px;
|
||||
background: #fafafa;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.answerSection {
|
||||
padding-left: 12px;
|
||||
|
||||
.answerItem {
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.correct {
|
||||
color: #52c41a;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.incorrect {
|
||||
color: #ff4d4f;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.aiGrading {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background: #f6ffed;
|
||||
border: 1px solid #b7eb8f;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actionsCard {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// 响应式适配
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 12px;
|
||||
|
||||
:global(.ant-result) {
|
||||
padding: 24px 16px;
|
||||
|
||||
:global(.ant-result-title) {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.statsCard {
|
||||
:global(.ant-col) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
:global(.ant-statistic) {
|
||||
:global(.ant-statistic-title) {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
:global(.ant-statistic-content) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.typeScoreCard {
|
||||
.typeScoreItem {
|
||||
padding: 12px;
|
||||
|
||||
.typeScoreHeader {
|
||||
gap: 2px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
:global(.ant-typography) {
|
||||
font-size: 14px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.typeScoreContent {
|
||||
margin-bottom: 8px;
|
||||
|
||||
:global(.ant-typography) {
|
||||
&:first-child {
|
||||
font-size: 22px !important;
|
||||
}
|
||||
&:last-child {
|
||||
font-size: 14px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.typeScoreProgress {
|
||||
height: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.detailCard {
|
||||
.questionItem {
|
||||
.answerSection {
|
||||
padding-left: 0;
|
||||
|
||||
.answerItem {
|
||||
flex-wrap: wrap;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actionsCard {
|
||||
:global(.ant-space) {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,588 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Card,
|
||||
Result,
|
||||
Button,
|
||||
Typography,
|
||||
Tag,
|
||||
Space,
|
||||
Spin,
|
||||
message,
|
||||
Row,
|
||||
Col,
|
||||
Statistic,
|
||||
Divider,
|
||||
} from "antd";
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
TrophyOutlined,
|
||||
ClockCircleOutlined,
|
||||
FileTextOutlined,
|
||||
LeftOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import * as examApi from "../api/exam";
|
||||
import type { ExamRecordResponse, ExamAnswer } from "../types/exam";
|
||||
import type { Question } from "../types/question";
|
||||
import styles from "./ExamResultNew.module.less";
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
|
||||
// 题型名称映射
|
||||
const TYPE_NAME: Record<string, string> = {
|
||||
"fill-in-blank": "填空题",
|
||||
"true-false": "判断题",
|
||||
"multiple-choice": "单选题",
|
||||
"multiple-selection": "多选题",
|
||||
"short-answer": "简答题",
|
||||
"ordinary-essay": "论述题",
|
||||
"management-essay": "论述题",
|
||||
essay: "论述题", // 合并后的论述题类型
|
||||
};
|
||||
|
||||
// 题型顺序定义
|
||||
const TYPE_ORDER: Record<string, number> = {
|
||||
"fill-in-blank": 1,
|
||||
"true-false": 2,
|
||||
"multiple-choice": 3,
|
||||
"multiple-selection": 4,
|
||||
"short-answer": 5,
|
||||
"ordinary-essay": 6,
|
||||
"management-essay": 6,
|
||||
essay: 6, // 合并后的论述题顺序
|
||||
};
|
||||
|
||||
const ExamResultNew: React.FC = () => {
|
||||
const { recordId } = useParams<{ recordId: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [data, setData] = useState<ExamRecordResponse | null>(null);
|
||||
const [questions, setQuestions] = useState<Question[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!recordId) {
|
||||
message.error("参数错误");
|
||||
navigate("/exam/management");
|
||||
return;
|
||||
}
|
||||
|
||||
loadResult();
|
||||
}, [recordId]);
|
||||
|
||||
const loadResult = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const recordRes = await examApi.getExamRecord(Number(recordId));
|
||||
|
||||
if (recordRes.success && recordRes.data) {
|
||||
setData(recordRes.data);
|
||||
|
||||
// 获取试卷详情
|
||||
if (recordRes.data.record.exam?.id) {
|
||||
const examRes = await examApi.getExamDetail(
|
||||
recordRes.data.record.exam.id
|
||||
);
|
||||
if (examRes.success && examRes.data) {
|
||||
setQuestions(examRes.data.questions);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
message.error("加载结果失败");
|
||||
navigate("/exam/management");
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.message || "加载结果失败");
|
||||
navigate("/exam/management");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.loadingContainer}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { record, answers } = data;
|
||||
const isPassed = record.is_passed;
|
||||
// 总分统一为100分
|
||||
const scorePercent = record.score;
|
||||
|
||||
// 构建答案映射
|
||||
const answerMap = new Map<number, ExamAnswer>();
|
||||
answers.forEach((ans) => {
|
||||
answerMap.set(ans.question_id, ans);
|
||||
});
|
||||
|
||||
// 统计正确率
|
||||
const correctCount = answers.filter((a) => a.is_correct).length;
|
||||
const totalCount = answers.length;
|
||||
const correctRate = totalCount > 0 ? (correctCount / totalCount) * 100 : 0;
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (seconds: number) => {
|
||||
const totalSeconds = Math.floor(seconds); // 确保是整数
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const secs = totalSeconds % 60;
|
||||
return `${minutes}分${secs}秒`;
|
||||
};
|
||||
|
||||
// 渲染答案详情
|
||||
const renderAnswerDetail = (question: Question, answer: ExamAnswer) => {
|
||||
const isCorrect = answer.is_correct;
|
||||
|
||||
return (
|
||||
<div className={styles.answerDetail}>
|
||||
{/* 题目内容 - 填空题特殊处理 */}
|
||||
<div className={styles.questionContent}>
|
||||
{question.type === "fill-in-blank" ? (
|
||||
<Paragraph strong style={{ fontSize: 16, marginBottom: 16 }}>
|
||||
{renderFillInBlankQuestion(question.content)}
|
||||
</Paragraph>
|
||||
) : (
|
||||
<Paragraph strong style={{ fontSize: 16, marginBottom: 16 }}>
|
||||
{question.content}
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.answerSection}>
|
||||
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
|
||||
{/* 用户答案 */}
|
||||
<div className={styles.answerItem}>
|
||||
<Space>
|
||||
<Text type="secondary">你的答案:</Text>
|
||||
<Text
|
||||
strong
|
||||
className={isCorrect ? styles.correct : styles.incorrect}
|
||||
>
|
||||
{formatAnswer(answer.answer, question.type)}
|
||||
</Text>
|
||||
{isCorrect ? (
|
||||
<CheckCircleOutlined style={{ color: "#52c41a" }} />
|
||||
) : (
|
||||
<CloseCircleOutlined style={{ color: "#ff4d4f" }} />
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 正确答案 */}
|
||||
<div className={styles.answerItem}>
|
||||
<Space>
|
||||
<Text type="secondary">正确答案:</Text>
|
||||
<Text strong style={{ color: "#52c41a" }}>
|
||||
{formatAnswer(answer.correct_answer, question.type)}
|
||||
</Text>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 得分 */}
|
||||
<div className={styles.answerItem}>
|
||||
<Space>
|
||||
<Text type="secondary">得分:</Text>
|
||||
<Text
|
||||
strong
|
||||
style={{
|
||||
color: isCorrect ? "#52c41a" : "#ff4d4f",
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
{answer.score.toFixed(1)} 分
|
||||
</Text>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* AI评分详情 */}
|
||||
{answer.ai_grading && (
|
||||
<div
|
||||
className={styles.aiGrading}
|
||||
style={{
|
||||
marginTop: 12,
|
||||
padding: 16,
|
||||
background: "#f0f5ff",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Text strong style={{ color: "#1890ff" }}>
|
||||
AI评分详情:
|
||||
</Text>
|
||||
</div>
|
||||
<Space
|
||||
direction="vertical"
|
||||
size="small"
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<div>
|
||||
<Text type="secondary">AI得分:</Text>
|
||||
<Text strong>{answer.ai_grading.score} / 100</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary">评语:</Text>
|
||||
<Text>{answer.ai_grading.feedback}</Text>
|
||||
</div>
|
||||
{answer.ai_grading.suggestion && (
|
||||
<div>
|
||||
<Text type="secondary">改进建议:</Text>
|
||||
<Text>{answer.ai_grading.suggestion}</Text>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染填空题题目(将 **** 替换为下划线)
|
||||
const renderFillInBlankQuestion = (content: string) => {
|
||||
const parts = content.split("****");
|
||||
return (
|
||||
<span>
|
||||
{parts.map((part, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{part}
|
||||
{i < parts.length - 1 && (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
minWidth: "120px",
|
||||
borderBottom: "2px solid #1890ff",
|
||||
marginLeft: 8,
|
||||
marginRight: 8,
|
||||
}}
|
||||
>
|
||||
|
||||
</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// 格式化用户答案
|
||||
const formatAnswer = (answer: any, type: string): string => {
|
||||
if (answer === null || answer === undefined || answer === "") {
|
||||
return "未作答";
|
||||
}
|
||||
|
||||
if (Array.isArray(answer)) {
|
||||
if (answer.length === 0) return "未作答";
|
||||
return answer
|
||||
.filter((a) => a !== null && a !== undefined && a !== "")
|
||||
.join("、");
|
||||
}
|
||||
|
||||
if (type === "true-false") {
|
||||
// 处理判断题:支持字符串和布尔值
|
||||
const answerStr = String(answer).toLowerCase();
|
||||
return answerStr === "true" ? "正确" : "错误";
|
||||
}
|
||||
|
||||
return String(answer);
|
||||
};
|
||||
|
||||
// 按题型分组(合并两种论述题)
|
||||
const groupedQuestions = questions.reduce((acc, q) => {
|
||||
// 将两种论述题统一为 'essay'
|
||||
const displayType =
|
||||
q.type === "ordinary-essay" || q.type === "management-essay"
|
||||
? "essay"
|
||||
: q.type;
|
||||
if (!acc[displayType]) {
|
||||
acc[displayType] = [];
|
||||
}
|
||||
acc[displayType].push(q);
|
||||
return acc;
|
||||
}, {} as Record<string, Question[]>);
|
||||
|
||||
// 计算各题型得分(已在 groupedQuestions 中合并论述题)
|
||||
const typeScores = Object.entries(groupedQuestions)
|
||||
.map(([type, qs]) => {
|
||||
const typeAnswers = qs
|
||||
.map((q) => answerMap.get(q.id))
|
||||
.filter(Boolean) as ExamAnswer[];
|
||||
const totalScore = typeAnswers.reduce((sum, ans) => sum + ans.score, 0);
|
||||
const maxScore =
|
||||
typeAnswers.length *
|
||||
(type === "fill-in-blank"
|
||||
? 2.0
|
||||
: type === "true-false"
|
||||
? 2.0
|
||||
: type === "multiple-choice"
|
||||
? 1.0
|
||||
: type === "multiple-selection"
|
||||
? 2.5
|
||||
: type === "short-answer"
|
||||
? 10.0
|
||||
: type === "essay" ||
|
||||
type === "ordinary-essay" ||
|
||||
type === "management-essay"
|
||||
? 5.0
|
||||
: 0);
|
||||
const correctCount = typeAnswers.filter((ans) => ans.is_correct).length;
|
||||
|
||||
return {
|
||||
type,
|
||||
typeName: TYPE_NAME[type] || type,
|
||||
totalScore,
|
||||
maxScore,
|
||||
correctCount,
|
||||
totalCount: typeAnswers.length,
|
||||
order: TYPE_ORDER[type] || 999,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.order - b.order);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* 顶部返回按钮 */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Button
|
||||
icon={<LeftOutlined />}
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
返回
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 成绩展示 */}
|
||||
<Result
|
||||
status={isPassed ? "success" : "warning"}
|
||||
title={isPassed ? "恭喜你,考试通过!" : "很遗憾,未通过考试"}
|
||||
subTitle={
|
||||
<Space direction="vertical" size="large">
|
||||
<Text style={{ fontSize: 16 }}>
|
||||
{record.exam?.title || "模拟考试"}
|
||||
</Text>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
!isPassed && record.exam?.id ? (
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await examApi.startExam(record.exam!.id);
|
||||
if (res.success && res.data) {
|
||||
navigate(`/exam/${record.exam!.id}/taking/${res.data.record_id}`);
|
||||
} else {
|
||||
message.error(res.message || "开始考试失败");
|
||||
}
|
||||
} catch (error) {
|
||||
message.error("开始考试失败");
|
||||
}
|
||||
}}
|
||||
>
|
||||
再考一次
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 成绩统计 */}
|
||||
<Card className={styles.statsCard}>
|
||||
<Row gutter={[32, 16]}>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Statistic
|
||||
title="总分"
|
||||
value={scorePercent.toFixed(1)}
|
||||
suffix="/ 100"
|
||||
prefix={<TrophyOutlined />}
|
||||
valueStyle={{
|
||||
color: isPassed ? "#52c41a" : "#ff4d4f",
|
||||
fontSize: 32,
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Statistic
|
||||
title="正确率"
|
||||
value={correctRate.toFixed(1)}
|
||||
suffix="%"
|
||||
prefix={<CheckCircleOutlined />}
|
||||
valueStyle={{ color: "#1890ff", fontSize: 32 }}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Statistic
|
||||
title="用时"
|
||||
value={formatTime(record.time_spent)}
|
||||
prefix={<ClockCircleOutlined />}
|
||||
valueStyle={{ fontSize: 32 }}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<div
|
||||
style={{
|
||||
color: "rgba(0, 0, 0, 0.45)",
|
||||
fontSize: 14,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
考试状态
|
||||
</div>
|
||||
<Tag
|
||||
color={isPassed ? "success" : "error"}
|
||||
style={{ fontSize: 16, padding: "4px 16px" }}
|
||||
>
|
||||
{isPassed ? "已通过" : "未通过"}
|
||||
</Tag>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
color: "rgba(0, 0, 0, 0.45)",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
及格分数:{record.exam?.pass_score || 60} 分
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 各题型得分情况 */}
|
||||
<Card
|
||||
title={
|
||||
<Text strong style={{ fontSize: 18 }}>
|
||||
题型得分统计
|
||||
</Text>
|
||||
}
|
||||
className={styles.typeScoreCard}
|
||||
>
|
||||
<Row gutter={[16, 16]}>
|
||||
{typeScores.map((ts) => (
|
||||
<Col xs={24} sm={12} md={8} lg={6} key={ts.type}>
|
||||
<div className={styles.typeScoreItem}>
|
||||
<div className={styles.typeScoreHeader}>
|
||||
<Text strong style={{ fontSize: 16 }}>
|
||||
{ts.typeName}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
({ts.correctCount}/{ts.totalCount}题正确)
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.typeScoreContent}>
|
||||
<Text
|
||||
strong
|
||||
style={{
|
||||
fontSize: 28,
|
||||
color:
|
||||
ts.totalScore === ts.maxScore ? "#52c41a" : "#1890ff",
|
||||
}}
|
||||
>
|
||||
{ts.totalScore.toFixed(1)}
|
||||
</Text>
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{ fontSize: 16, marginLeft: 4 }}
|
||||
>
|
||||
/ {ts.maxScore.toFixed(1)}
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.typeScoreProgress}>
|
||||
<div
|
||||
className={styles.typeScoreBar}
|
||||
style={{
|
||||
width: `${(ts.totalScore / ts.maxScore) * 100}%`,
|
||||
background:
|
||||
ts.totalScore === ts.maxScore ? "#52c41a" : "#1890ff",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 答题详情 - 直接展示,不使用折叠 */}
|
||||
<Card
|
||||
title={
|
||||
<Text strong style={{ fontSize: 18 }}>
|
||||
答题详情
|
||||
</Text>
|
||||
}
|
||||
className={styles.detailCard}
|
||||
>
|
||||
{Object.entries(groupedQuestions)
|
||||
.sort(([typeA], [typeB]) => {
|
||||
const orderA = TYPE_ORDER[typeA] || 999;
|
||||
const orderB = TYPE_ORDER[typeB] || 999;
|
||||
return orderA - orderB;
|
||||
})
|
||||
.map(([type, qs]) => (
|
||||
<div key={type} style={{ marginBottom: 32 }}>
|
||||
{/* 题型标题 */}
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
background: "#fafafa",
|
||||
borderLeft: "4px solid #1890ff",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Space>
|
||||
<Text strong style={{ fontSize: 16 }}>
|
||||
{TYPE_NAME[type] || type}
|
||||
</Text>
|
||||
<Text type="secondary">(共 {qs.length} 题)</Text>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 题目列表 */}
|
||||
<Space
|
||||
direction="vertical"
|
||||
size="large"
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{qs.map((q, idx) => {
|
||||
const ans = answerMap.get(q.id);
|
||||
if (!ans) return null;
|
||||
return (
|
||||
<Card
|
||||
key={q.id}
|
||||
size="small"
|
||||
className={styles.questionCard}
|
||||
style={{
|
||||
borderLeft: ans.is_correct
|
||||
? "4px solid #52c41a"
|
||||
: "4px solid #ff4d4f",
|
||||
background: ans.is_correct ? "#f6ffed" : "#fff2f0",
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Tag color="blue">第 {idx + 1} 题</Tag>
|
||||
</div>
|
||||
{renderAnswerDetail(q, ans)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
|
||||
{/* 题型之间的分隔线 */}
|
||||
<Divider />
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExamResultNew;
|
||||
@ -1,400 +0,0 @@
|
||||
.loadingContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 20px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
|
||||
:global(.ant-card-body) {
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.headerContent {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #1f2937;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
> div:first-child {
|
||||
:global(.ant-typography) {
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
|
||||
:global(.ant-statistic-title) {
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
:global(.ant-statistic-content) {
|
||||
color: #1f2937;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:global(.anticon) {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
:global(.ant-divider-vertical) {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.statCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 120px;
|
||||
|
||||
.statLabel {
|
||||
font-size: 14px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.statValue {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
:global(.ant-typography) {
|
||||
color: #1f2937;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.progressInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
|
||||
:global(.ant-typography) {
|
||||
color: #1f2937;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
|
||||
:global(.ant-card-body) {
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.questionContainer {
|
||||
min-height: 400px;
|
||||
padding: 24px 0;
|
||||
|
||||
.questionHeader {
|
||||
margin-bottom: 20px;
|
||||
|
||||
:global(.ant-tag) {
|
||||
padding: 4px 16px;
|
||||
font-size: 14px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.ant-form-item-label > label) {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #1f2937;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
:global(.ant-input),
|
||||
:global(.ant-input-textarea) {
|
||||
border-radius: 4px;
|
||||
border: 1px solid #d9d9d9;
|
||||
|
||||
&:hover {
|
||||
border-color: #40a9ff;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: #40a9ff;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
:global(.ant-radio-wrapper),
|
||||
:global(.ant-checkbox-wrapper) {
|
||||
font-size: 15px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.navigation {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 24px 0;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.questionGroup {
|
||||
margin-bottom: 32px;
|
||||
|
||||
.groupHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:global {
|
||||
.ant-form-item-label > label {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 32px 0;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
}
|
||||
|
||||
// 抽屉样式
|
||||
.drawerContent {
|
||||
.questionTypeSection {
|
||||
margin-bottom: 12px;
|
||||
|
||||
.typeHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
background: #fafafa;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
:global(.ant-typography) {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.questionGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(10, 1fr);
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
|
||||
.questionItem {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
&.current {
|
||||
border-color: #1890ff;
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&.answered:not(.current) {
|
||||
border-color: #52c41a;
|
||||
background: #f6ffed;
|
||||
color: #52c41a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
padding: 16px 0 0 0;
|
||||
margin-top: 12px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
|
||||
.legendItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.legendBox {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.current {
|
||||
border-color: #1890ff;
|
||||
background: #1890ff;
|
||||
}
|
||||
|
||||
&.answered {
|
||||
border-color: #52c41a;
|
||||
background: #f6ffed;
|
||||
}
|
||||
|
||||
&.unanswered {
|
||||
border-color: #d9d9d9;
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.ant-typography) {
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式适配
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.header {
|
||||
.headerContent {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.stats {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
|
||||
:global(.ant-divider) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:global(.ant-statistic) {
|
||||
:global(.ant-statistic-title) {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
:global(.ant-statistic-content) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.progressInfo {
|
||||
:global(.ant-typography) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
.questionContainer {
|
||||
min-height: 300px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.navigation {
|
||||
padding: 16px 0;
|
||||
|
||||
:global(.ant-space) {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
.questionGroup {
|
||||
.groupHeader {
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
:global {
|
||||
.ant-form-item-label > label {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
padding: 20px 0;
|
||||
|
||||
:global(.ant-space) {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 抽屉响应式
|
||||
.drawerContent {
|
||||
.questionGrid {
|
||||
grid-template-columns: repeat(10, 1fr);
|
||||
gap: 8px;
|
||||
|
||||
.questionItem {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,728 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Radio,
|
||||
Checkbox,
|
||||
Button,
|
||||
Typography,
|
||||
message,
|
||||
Spin,
|
||||
Space,
|
||||
Divider,
|
||||
Modal,
|
||||
Statistic,
|
||||
FloatButton,
|
||||
Drawer,
|
||||
Tag
|
||||
} from 'antd'
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
LeftOutlined,
|
||||
RightOutlined,
|
||||
UnorderedListOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import * as examApi from '../api/exam'
|
||||
import type { Question } from '../types/question'
|
||||
import type { ExamDetailResponse } from '../types/exam'
|
||||
import styles from './ExamTaking.module.less'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
const { TextArea } = Input
|
||||
const { Countdown } = Statistic
|
||||
|
||||
// 题型名称映射
|
||||
const TYPE_NAME: Record<string, string> = {
|
||||
'fill-in-blank': '填空题',
|
||||
'true-false': '判断题',
|
||||
'multiple-choice': '单选题',
|
||||
'multiple-selection': '多选题',
|
||||
'short-answer': '简答题',
|
||||
'ordinary-essay': '论述题',
|
||||
'management-essay': '论述题'
|
||||
}
|
||||
|
||||
// 题型顺序定义
|
||||
const TYPE_ORDER: Record<string, number> = {
|
||||
'fill-in-blank': 1,
|
||||
'true-false': 2,
|
||||
'multiple-choice': 3,
|
||||
'multiple-selection': 4,
|
||||
'short-answer': 5,
|
||||
'ordinary-essay': 6,
|
||||
'management-essay': 6
|
||||
}
|
||||
|
||||
const ExamTaking: React.FC = () => {
|
||||
const { examId, recordId } = useParams<{ examId: string; recordId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [examData, setExamData] = useState<ExamDetailResponse | null>(null)
|
||||
const [groupedQuestions, setGroupedQuestions] = useState<Record<string, Question[]>>({})
|
||||
const [answeredCount, setAnsweredCount] = useState(0)
|
||||
const [endTime, setEndTime] = useState<number>(0)
|
||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0)
|
||||
const [drawerVisible, setDrawerVisible] = useState(false)
|
||||
|
||||
// 加载考试详情
|
||||
useEffect(() => {
|
||||
if (!examId || !recordId) {
|
||||
message.error('参数错误')
|
||||
navigate('/exam/management')
|
||||
return
|
||||
}
|
||||
|
||||
const loadExam = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await examApi.getExamDetail(Number(examId))
|
||||
if (res.success && res.data) {
|
||||
// 获取用户信息,判断用户类型
|
||||
const userStr = localStorage.getItem('user')
|
||||
const user = userStr ? JSON.parse(userStr) : null
|
||||
const userType = user?.user_type // 'ordinary-person' 或 'management-person'
|
||||
|
||||
// 过滤题目:根据用户类型只保留对应的论述题
|
||||
let filteredQuestions = res.data.questions.filter(q => {
|
||||
if (q.type === 'ordinary-essay') {
|
||||
return userType === 'ordinary-person'
|
||||
}
|
||||
if (q.type === 'management-essay') {
|
||||
return userType === 'management-person'
|
||||
}
|
||||
return true // 其他题目全部保留
|
||||
})
|
||||
|
||||
// 按照题型顺序排序题目
|
||||
filteredQuestions.sort((a, b) => {
|
||||
const orderA = TYPE_ORDER[a.type] || 999
|
||||
const orderB = TYPE_ORDER[b.type] || 999
|
||||
return orderA - orderB
|
||||
})
|
||||
|
||||
setExamData({
|
||||
...res.data,
|
||||
questions: filteredQuestions
|
||||
})
|
||||
|
||||
// 按题型分组
|
||||
const grouped = groupQuestionsByType(filteredQuestions)
|
||||
setGroupedQuestions(grouped)
|
||||
|
||||
// 检查是否有保存的剩余时间(暂停状态)
|
||||
const savedProgress = localStorage.getItem(`exam_progress_${recordId}`)
|
||||
if (savedProgress) {
|
||||
try {
|
||||
const progress = JSON.parse(savedProgress)
|
||||
if (progress.remainingTime) {
|
||||
// 恢复暂停时的剩余时间
|
||||
setEndTime(Date.now() + progress.remainingTime)
|
||||
} else {
|
||||
// 没有暂停记录,使用完整考试时长
|
||||
const duration = res.data.exam.duration * 60 * 1000
|
||||
setEndTime(Date.now() + duration)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析进度失败', e)
|
||||
const duration = res.data.exam.duration * 60 * 1000
|
||||
setEndTime(Date.now() + duration)
|
||||
}
|
||||
} else {
|
||||
// 首次进入,使用完整考试时长
|
||||
const duration = res.data.exam.duration * 60 * 1000
|
||||
setEndTime(Date.now() + duration)
|
||||
}
|
||||
|
||||
// 恢复答题进度(先从服务器,再从localStorage)
|
||||
await loadProgressFromServer()
|
||||
} else {
|
||||
message.error('加载考试失败')
|
||||
navigate('/exam/management')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.message || '加载考试失败')
|
||||
navigate('/exam/management')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadExam()
|
||||
}, [examId, recordId, navigate])
|
||||
|
||||
// 定时保存进度(每30秒)
|
||||
useEffect(() => {
|
||||
if (!recordId) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
saveCurrentQuestionToServer()
|
||||
}, 30000) // 30秒
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [recordId, currentQuestionIndex, examData]) // 依赖当前题目索引
|
||||
|
||||
// 按题型分组题目
|
||||
const groupQuestionsByType = (questions: Question[]) => {
|
||||
const grouped: Record<string, Question[]> = {}
|
||||
questions.forEach((q) => {
|
||||
if (!grouped[q.type]) {
|
||||
grouped[q.type] = []
|
||||
}
|
||||
grouped[q.type].push(q)
|
||||
})
|
||||
return grouped
|
||||
}
|
||||
|
||||
// 保存当前题目答案到数据库
|
||||
const saveCurrentQuestionToServer = async () => {
|
||||
if (!recordId || !examData || currentQuestionIndex < 0) return
|
||||
|
||||
try {
|
||||
const currentQuestion = examData.questions[currentQuestionIndex]
|
||||
const fieldName = `q_${currentQuestion.id}`
|
||||
const answer = form.getFieldValue(fieldName)
|
||||
|
||||
// 只有当答案不为空时才保存
|
||||
if (answer !== undefined && answer !== null && answer !== '') {
|
||||
await examApi.saveExamProgress(Number(recordId), {
|
||||
question_id: currentQuestion.id,
|
||||
answer: answer
|
||||
})
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('保存答案失败:', error)
|
||||
// 静默失败,不打扰用户
|
||||
}
|
||||
}
|
||||
|
||||
// 保存答题进度(仅localStorage,用于快速保存)
|
||||
const saveProgress = () => {
|
||||
if (!recordId) return
|
||||
const values = form.getFieldsValue()
|
||||
const remaining = endTime - Date.now()
|
||||
const progress = {
|
||||
answers: values,
|
||||
remainingTime: remaining > 0 ? remaining : 0,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
localStorage.setItem(`exam_progress_${recordId}`, JSON.stringify(progress))
|
||||
}
|
||||
|
||||
// 从服务器恢复答题进度
|
||||
const loadProgressFromServer = async () => {
|
||||
if (!recordId) return
|
||||
|
||||
try {
|
||||
// 1. 先尝试从服务器加载答案
|
||||
const res = await examApi.getExamUserAnswers(Number(recordId))
|
||||
if (res.success && res.data && Object.keys(res.data).length > 0) {
|
||||
form.setFieldsValue(res.data)
|
||||
updateAnsweredCount(res.data)
|
||||
message.success('已恢复服务器保存的答题进度')
|
||||
return
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('从服务器恢复进度失败:', error)
|
||||
}
|
||||
|
||||
// 2. 如果服务器没有数据,尝试从localStorage恢复
|
||||
const saved = localStorage.getItem(`exam_progress_${recordId}`)
|
||||
if (saved) {
|
||||
try {
|
||||
const progress = JSON.parse(saved)
|
||||
if (progress.answers) {
|
||||
form.setFieldsValue(progress.answers)
|
||||
updateAnsweredCount(progress.answers)
|
||||
message.success('已恢复本地保存的答题进度')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('恢复本地进度失败', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新已答题目数量
|
||||
const updateAnsweredCount = (values: any) => {
|
||||
let count = 0
|
||||
Object.values(values).forEach((val: any) => {
|
||||
if (val !== undefined && val !== null && val !== '') {
|
||||
if (Array.isArray(val) && val.length > 0) {
|
||||
count++
|
||||
} else if (!Array.isArray(val)) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
})
|
||||
setAnsweredCount(count)
|
||||
}
|
||||
|
||||
// 监听表单变化
|
||||
const handleFormChange = () => {
|
||||
const values = form.getFieldsValue()
|
||||
updateAnsweredCount(values)
|
||||
saveProgress()
|
||||
}
|
||||
|
||||
// 提交考试
|
||||
const handleSubmit = async () => {
|
||||
const totalQuestions = examData?.questions.length || 0
|
||||
const unanswered = totalQuestions - answeredCount
|
||||
|
||||
// 先保存当前题目答案
|
||||
await saveCurrentQuestionToServer()
|
||||
|
||||
if (unanswered > 0) {
|
||||
Modal.confirm({
|
||||
title: '确认提交',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: `还有 ${unanswered} 道题未作答,确认提交吗?`,
|
||||
okText: '确认提交',
|
||||
cancelText: '继续答题',
|
||||
onOk: () => submitExam()
|
||||
})
|
||||
} else {
|
||||
Modal.confirm({
|
||||
title: '确认提交',
|
||||
icon: <CheckCircleOutlined />,
|
||||
content: '已完成所有题目,确认提交吗?',
|
||||
okText: '确认提交',
|
||||
cancelText: '检查答案',
|
||||
onOk: () => submitExam()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 提交答案并触发阅卷
|
||||
const submitExam = async () => {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
// 直接提交,后端会从数据库读取答案并阅卷
|
||||
const res = await examApi.submitExamAnswer(Number(recordId), {})
|
||||
if (res.success) {
|
||||
message.success('提交成功,正在阅卷...')
|
||||
// 清除进度
|
||||
localStorage.removeItem(`exam_progress_${recordId}`)
|
||||
// 跳转到试卷列表页面
|
||||
navigate('/exam/management')
|
||||
} else {
|
||||
message.error(res.message || '提交失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.message || '提交失败')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 时间到自动提交
|
||||
const handleTimeFinish = async () => {
|
||||
message.warning('考试时间已到,系统将自动提交')
|
||||
await saveCurrentQuestionToServer() // 先保存当前答案
|
||||
submitExam()
|
||||
}
|
||||
|
||||
// 上一题
|
||||
const handlePrevQuestion = async () => {
|
||||
if (currentQuestionIndex > 0) {
|
||||
await saveCurrentQuestionToServer() // 保存当前题目答案到服务器
|
||||
setCurrentQuestionIndex(currentQuestionIndex - 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 下一题
|
||||
const handleNextQuestion = async () => {
|
||||
if (examData && currentQuestionIndex < examData.questions.length - 1) {
|
||||
await saveCurrentQuestionToServer() // 保存当前题目答案到服务器
|
||||
setCurrentQuestionIndex(currentQuestionIndex + 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到指定题目
|
||||
const handleJumpToQuestion = (index: number) => {
|
||||
setCurrentQuestionIndex(index)
|
||||
setDrawerVisible(false)
|
||||
}
|
||||
|
||||
// 检查题目是否已答
|
||||
const isQuestionAnswered = (question: Question): boolean => {
|
||||
const fieldName = `q_${question.id}`
|
||||
const value = form.getFieldValue(fieldName)
|
||||
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// 处理多选题和填空题
|
||||
return value.length > 0 && value.some(v => v !== undefined && v !== null && v !== '')
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// 渲染题目
|
||||
const renderQuestion = (question: Question) => {
|
||||
const fieldName = `q_${question.id}`
|
||||
|
||||
switch (question.type) {
|
||||
case 'fill-in-blank':
|
||||
// 将题目按 **** 分割,在占位符位置插入输入框
|
||||
const parts = question.content.split('****')
|
||||
const blankCount = parts.length - 1 // 填空数量 = 分割后的部分数 - 1
|
||||
|
||||
if (blankCount === 0) {
|
||||
// 如果没有 ****,显示警告并提供一个文本框
|
||||
return (
|
||||
<Form.Item
|
||||
key={question.id}
|
||||
label={question.content}
|
||||
required={false}
|
||||
>
|
||||
<div style={{ marginBottom: 12, padding: '12px', background: '#fff7e6', border: '1px solid #ffd591', borderRadius: '4px' }}>
|
||||
<Text type="warning" style={{ fontSize: 14 }}>
|
||||
提示:题目格式错误,缺少 **** 占位符,请联系管理员修正。
|
||||
</Text>
|
||||
</div>
|
||||
<Form.Item
|
||||
name={[fieldName, 0]}
|
||||
rules={[{ required: true, message: '请填写答案' }]}
|
||||
noStyle
|
||||
>
|
||||
<Input.TextArea
|
||||
placeholder="请在此处填写答案"
|
||||
autoSize={{ minRows: 2, maxRows: 6 }}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderBottom: '2px solid #1890ff',
|
||||
borderRadius: 0,
|
||||
padding: '4px 0',
|
||||
boxShadow: 'none'
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Form.Item
|
||||
key={question.id}
|
||||
required={false}
|
||||
style={{ marginBottom: 24 }}
|
||||
>
|
||||
<div style={{ fontSize: 16, fontWeight: 500, color: '#1f2937', lineHeight: 2, display: 'flex', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{parts.map((part, i) => (
|
||||
<React.Fragment key={i}>
|
||||
<span>{part}</span>
|
||||
{i < blankCount && (
|
||||
<Form.Item
|
||||
name={[fieldName, i]}
|
||||
rules={[{ required: true, message: '请填写' }]}
|
||||
style={{ display: 'inline-block', margin: '0 8px 0 8px' }}
|
||||
>
|
||||
<Input
|
||||
placeholder={`填空 ${i + 1}`}
|
||||
style={{
|
||||
width: 180,
|
||||
border: 'none',
|
||||
borderBottom: '2px solid #1890ff',
|
||||
borderRadius: 0,
|
||||
padding: '4px 0',
|
||||
boxShadow: 'none'
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</Form.Item>
|
||||
)
|
||||
|
||||
case 'true-false':
|
||||
return (
|
||||
<Form.Item
|
||||
key={question.id}
|
||||
name={fieldName}
|
||||
label={question.content}
|
||||
rules={[{ required: true, message: '请选择答案' }]}
|
||||
required={false}
|
||||
>
|
||||
<Radio.Group>
|
||||
<Space direction="vertical">
|
||||
<Radio value="true">正确</Radio>
|
||||
<Radio value="false">错误</Radio>
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
)
|
||||
|
||||
case 'multiple-choice':
|
||||
return (
|
||||
<Form.Item
|
||||
key={question.id}
|
||||
name={fieldName}
|
||||
label={question.content}
|
||||
rules={[{ required: true, message: '请选择答案' }]}
|
||||
required={false}
|
||||
>
|
||||
<Radio.Group>
|
||||
<Space direction="vertical">
|
||||
{question.options?.map((opt) => (
|
||||
<Radio key={opt.key} value={opt.key}>
|
||||
{opt.key}. {opt.value}
|
||||
</Radio>
|
||||
))}
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
)
|
||||
|
||||
case 'multiple-selection':
|
||||
return (
|
||||
<Form.Item
|
||||
key={question.id}
|
||||
name={fieldName}
|
||||
label={question.content}
|
||||
rules={[{ required: true, message: '请选择答案' }]}
|
||||
required={false}
|
||||
>
|
||||
<Checkbox.Group>
|
||||
<Space direction="vertical">
|
||||
{question.options?.map((opt) => (
|
||||
<Checkbox key={opt.key} value={opt.key}>
|
||||
{opt.key}. {opt.value}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Space>
|
||||
</Checkbox.Group>
|
||||
</Form.Item>
|
||||
)
|
||||
|
||||
case 'short-answer':
|
||||
case 'ordinary-essay':
|
||||
case 'management-essay':
|
||||
return (
|
||||
<Form.Item
|
||||
key={question.id}
|
||||
name={fieldName}
|
||||
label={question.content}
|
||||
rules={[{ required: true, message: '请作答' }]}
|
||||
required={false}
|
||||
>
|
||||
<TextArea rows={6} placeholder="请输入你的答案" />
|
||||
</Form.Item>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.loadingContainer}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!examData) {
|
||||
return null
|
||||
}
|
||||
|
||||
const totalQuestions = examData.questions.length
|
||||
const currentQuestion = examData.questions[currentQuestionIndex]
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* 顶部信息栏 */}
|
||||
<Card className={styles.header}>
|
||||
<div className={styles.headerContent}>
|
||||
<div>
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: '确认退出',
|
||||
content: '退出将保存当前答题进度,确认退出吗?',
|
||||
onOk: () => {
|
||||
saveProgress()
|
||||
navigate('/exam/management')
|
||||
}
|
||||
})
|
||||
}}
|
||||
style={{ marginBottom: 8 }}
|
||||
>
|
||||
退出考试
|
||||
</Button>
|
||||
<Title level={3}>{examData.exam.title}</Title>
|
||||
<Text type="secondary">
|
||||
题目 {currentQuestionIndex + 1}/{totalQuestions}
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.stats}>
|
||||
<div className={styles.statCard}>
|
||||
<Text type="secondary" className={styles.statLabel}>剩余时间</Text>
|
||||
<div className={styles.statValue}>
|
||||
<ClockCircleOutlined style={{ color: '#1890ff', fontSize: 20 }} />
|
||||
<Countdown
|
||||
value={endTime}
|
||||
format="mm:ss"
|
||||
onFinish={handleTimeFinish}
|
||||
style={{ fontSize: 24, fontWeight: 600, color: '#1f2937', lineHeight: 1 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Divider type="vertical" style={{ height: 60 }} />
|
||||
<div className={styles.statCard}>
|
||||
<Text type="secondary" className={styles.statLabel}>答题进度</Text>
|
||||
<div className={styles.statValue}>
|
||||
<CheckCircleOutlined style={{ color: '#1890ff', fontSize: 20 }} />
|
||||
<Text strong style={{ fontSize: 24, lineHeight: 1 }}>
|
||||
{answeredCount}/{totalQuestions}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 答题区域 - 单题显示 */}
|
||||
<Card className={styles.content}>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onValuesChange={handleFormChange}
|
||||
>
|
||||
{currentQuestion && (
|
||||
<div className={styles.questionContainer}>
|
||||
<div className={styles.questionHeader}>
|
||||
<Text strong style={{ fontSize: 16, marginRight: 8 }}>
|
||||
第{(() => {
|
||||
const typeQuestions = groupedQuestions[currentQuestion.type] || []
|
||||
const typeIndex = typeQuestions.findIndex(q => q.id === currentQuestion.id)
|
||||
return typeIndex + 1
|
||||
})()}题
|
||||
</Text>
|
||||
<Tag color="blue">{TYPE_NAME[currentQuestion.type] || currentQuestion.type}</Tag>
|
||||
</div>
|
||||
{renderQuestion(currentQuestion)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 导航按钮 */}
|
||||
<div className={styles.navigation}>
|
||||
<Space size="large">
|
||||
<Button
|
||||
icon={<LeftOutlined />}
|
||||
onClick={handlePrevQuestion}
|
||||
disabled={currentQuestionIndex === 0}
|
||||
>
|
||||
上一题
|
||||
</Button>
|
||||
<Text type="secondary">
|
||||
{currentQuestionIndex + 1} / {totalQuestions}
|
||||
</Text>
|
||||
<Button
|
||||
icon={<RightOutlined />}
|
||||
onClick={handleNextQuestion}
|
||||
disabled={currentQuestionIndex === totalQuestions - 1}
|
||||
>
|
||||
下一题
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CheckCircleOutlined />}
|
||||
onClick={handleSubmit}
|
||||
loading={submitting}
|
||||
>
|
||||
提交答卷
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
{/* 悬浮球 */}
|
||||
<FloatButton
|
||||
icon={<UnorderedListOutlined />}
|
||||
type="primary"
|
||||
style={{ right: 24, bottom: 24 }}
|
||||
onClick={() => setDrawerVisible(true)}
|
||||
badge={{ count: answeredCount, overflowCount: 999 }}
|
||||
/>
|
||||
|
||||
{/* 答题情况抽屉 */}
|
||||
<Drawer
|
||||
title="答题情况"
|
||||
placement="right"
|
||||
width={500}
|
||||
open={drawerVisible}
|
||||
onClose={() => setDrawerVisible(false)}
|
||||
>
|
||||
<div className={styles.drawerContent}>
|
||||
{/* 按题型分组显示 */}
|
||||
{Object.entries(groupedQuestions).sort(([typeA], [typeB]) => {
|
||||
const orderA = TYPE_ORDER[typeA] || 999
|
||||
const orderB = TYPE_ORDER[typeB] || 999
|
||||
return orderA - orderB
|
||||
}).map(([type, questions]) => {
|
||||
return (
|
||||
<div key={type} className={styles.questionTypeSection}>
|
||||
<div className={styles.typeHeader}>
|
||||
<Text strong>{TYPE_NAME[type] || type}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
共 {questions.length} 题
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.questionGrid}>
|
||||
{questions.map((q, idx) => {
|
||||
const globalIndex = examData.questions.findIndex(eq => eq.id === q.id)
|
||||
const isAnswered = isQuestionAnswered(q)
|
||||
const isCurrent = globalIndex === currentQuestionIndex
|
||||
return (
|
||||
<div
|
||||
key={q.id}
|
||||
className={`${styles.questionItem} ${isCurrent ? styles.current : ''} ${isAnswered ? styles.answered : ''}`}
|
||||
onClick={() => handleJumpToQuestion(globalIndex)}
|
||||
title={`第 ${idx + 1} 题`}
|
||||
>
|
||||
{idx + 1}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<div className={styles.legend}>
|
||||
<div className={styles.legendItem}>
|
||||
<div className={`${styles.legendBox} ${styles.current}`}></div>
|
||||
<Text>当前题目</Text>
|
||||
</div>
|
||||
<div className={styles.legendItem}>
|
||||
<div className={`${styles.legendBox} ${styles.answered}`}></div>
|
||||
<Text>已答题目</Text>
|
||||
</div>
|
||||
<div className={styles.legendItem}>
|
||||
<div className={`${styles.legendBox} ${styles.unanswered}`}></div>
|
||||
<Text>未答题目</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExamTaking
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,103 +1,181 @@
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fafafa;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(40px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(40px) saturate(180%);
|
||||
box-shadow:
|
||||
0 4px 24px rgba(0, 0, 0, 0.06),
|
||||
0 2px 12px rgba(0, 0, 0, 0.04),
|
||||
0 0 0 1px rgba(0, 0, 0, 0.03);
|
||||
border: 0.5px solid rgba(0, 0, 0, 0.04);
|
||||
border-radius: 24px;
|
||||
|
||||
:global {
|
||||
.ant-card-body {
|
||||
padding: 40px 32px;
|
||||
}
|
||||
}
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 0 8px 0 !important;
|
||||
color: #007aff !important;
|
||||
font-size: 56px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 1px;
|
||||
color: #ffffff;
|
||||
margin: 0 0 12px 0;
|
||||
letter-spacing: 3px;
|
||||
text-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 15px;
|
||||
display: block;
|
||||
color: #6e6e73;
|
||||
font-size: 16px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin: 0;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.registerTip {
|
||||
text-align: center;
|
||||
margin-top: 16px;
|
||||
font-size: 14px;
|
||||
.form {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.inputGroup {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
font-size: 15px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
color: #333;
|
||||
transition: all 0.3s ease;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
|
||||
&::placeholder {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: rgba(255, 255, 255, 0.6);
|
||||
background: #ffffff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: #ffffff;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.passwordWrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.passwordWrapper .input {
|
||||
padding-right: 48px;
|
||||
}
|
||||
|
||||
.eyeButton {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.2s ease;
|
||||
font-size: 20px;
|
||||
|
||||
&:hover {
|
||||
color: #667eea;
|
||||
}
|
||||
}
|
||||
|
||||
.submitButton {
|
||||
margin-top: 32px;
|
||||
height: 52px;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
color: #667eea;
|
||||
border: none;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:not(:disabled):hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
&:not(:disabled):active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
:global {
|
||||
.ant-typography {
|
||||
margin-right: 4px;
|
||||
.adm-button {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
color: #667eea !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计 - 移动端
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 12px;
|
||||
.switchMode {
|
||||
margin-top: 32px;
|
||||
text-align: center;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.content {
|
||||
max-width: 100%;
|
||||
}
|
||||
.linkBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
padding: 0 0 0 8px;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 15px;
|
||||
text-decoration: underline;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
|
||||
.card {
|
||||
:global {
|
||||
.ant-card-body {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
&:hover:not(:disabled) {
|
||||
opacity: 0.85;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 28px !important;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计 - 平板和PC端
|
||||
@media (min-width: 769px) {
|
||||
.title {
|
||||
font-size: 36px !important;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,54 +1,34 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Form, Input, Button, Card, Modal, message, Typography, Radio, Alert, Checkbox } from 'antd'
|
||||
import { UserOutlined, LockOutlined, IdcardOutlined } from '@ant-design/icons'
|
||||
import { fetchWithAuth } from '../utils/request'
|
||||
import { Button, Toast } from 'antd-mobile'
|
||||
import { EyeInvisibleOutline, EyeOutline } from 'antd-mobile-icons'
|
||||
import styles from './Login.module.less'
|
||||
|
||||
const { Title, Text, Link } = Typography
|
||||
|
||||
interface LoginResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
need_user_type?: boolean // 是否需要补充用户类型
|
||||
data?: {
|
||||
token: string
|
||||
user: {
|
||||
username: string
|
||||
avatar: string
|
||||
nickname: string
|
||||
user_type?: string // 用户类型
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const [isLogin, setIsLogin] = useState(true)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [registerModalVisible, setRegisterModalVisible] = useState(false)
|
||||
const [userTypeModalVisible, setUserTypeModalVisible] = useState(false) // 用户类型补充模态框
|
||||
const [rememberMe, setRememberMe] = useState(false) // 记住密码状态
|
||||
// const [userType, setUserType] = useState<string>('') // 临时存储用户选择的类型
|
||||
const [loginForm] = Form.useForm()
|
||||
const [registerForm] = Form.useForm()
|
||||
const [userTypeForm] = Form.useForm()
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
|
||||
// 页面加载时读取保存的登录信息
|
||||
useEffect(() => {
|
||||
const savedUsername = localStorage.getItem('savedUsername')
|
||||
const savedPassword = localStorage.getItem('savedPassword')
|
||||
const savedRememberMe = localStorage.getItem('rememberMe') === 'true'
|
||||
// 表单字段
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [nickname, setNickname] = useState('')
|
||||
|
||||
if (savedRememberMe && savedUsername && savedPassword) {
|
||||
loginForm.setFieldsValue({
|
||||
username: savedUsername,
|
||||
password: savedPassword
|
||||
})
|
||||
setRememberMe(true)
|
||||
}
|
||||
}, [loginForm])
|
||||
|
||||
// 如果已登录,重定向到首页
|
||||
// 如果已登录,重定向到首页
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
@ -57,46 +37,48 @@ const Login: React.FC = () => {
|
||||
}, [navigate])
|
||||
|
||||
// 处理登录
|
||||
const handleLogin = async (values: { username: string; password: string }) => {
|
||||
const handleLogin = async () => {
|
||||
if (!username || !password) {
|
||||
Toast.show({
|
||||
icon: 'fail',
|
||||
content: '请输入用户名和密码',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/login', {
|
||||
const response = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(values),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
|
||||
const data: LoginResponse = await response.json()
|
||||
|
||||
if (data.success && data.data) {
|
||||
// 保存登录凭证
|
||||
localStorage.setItem('token', data.data.token)
|
||||
localStorage.setItem('user', JSON.stringify(data.data.user))
|
||||
|
||||
// 处理记住密码
|
||||
if (rememberMe) {
|
||||
localStorage.setItem('savedUsername', values.username)
|
||||
localStorage.setItem('savedPassword', values.password)
|
||||
localStorage.setItem('rememberMe', 'true')
|
||||
} else {
|
||||
// 取消记住密码时清除保存的信息
|
||||
localStorage.removeItem('savedUsername')
|
||||
localStorage.removeItem('savedPassword')
|
||||
localStorage.removeItem('rememberMe')
|
||||
}
|
||||
Toast.show({
|
||||
icon: 'success',
|
||||
content: '登录成功',
|
||||
})
|
||||
|
||||
// 检查是否需要补充用户类型
|
||||
if (data.need_user_type) {
|
||||
setUserTypeModalVisible(true)
|
||||
} else {
|
||||
message.success('登录成功')
|
||||
// 使用 window.location 跳转,刷新页面以正确加载首页的聊天插件
|
||||
window.location.href = '/'
|
||||
}
|
||||
navigate('/')
|
||||
} else {
|
||||
message.error(data.message || '登录失败')
|
||||
Toast.show({
|
||||
icon: 'fail',
|
||||
content: data.message || '登录失败',
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
message.error('网络错误,请稍后重试')
|
||||
Toast.show({
|
||||
icon: 'fail',
|
||||
content: '网络错误,请稍后重试',
|
||||
})
|
||||
console.error('登录错误:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
@ -104,12 +86,31 @@ const Login: React.FC = () => {
|
||||
}
|
||||
|
||||
// 处理注册
|
||||
const handleRegister = async (values: { username: string; password: string; nickname?: string; user_type: string }) => {
|
||||
const handleRegister = async () => {
|
||||
if (!username || !password) {
|
||||
Toast.show({
|
||||
icon: 'fail',
|
||||
content: '请输入用户名和密码',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
Toast.show({
|
||||
icon: 'fail',
|
||||
content: '密码长度至少6位',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/register', {
|
||||
const response = await fetch('/api/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(values),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ username, password, nickname }),
|
||||
})
|
||||
|
||||
const data: LoginResponse = await response.json()
|
||||
@ -118,230 +119,132 @@ const Login: React.FC = () => {
|
||||
localStorage.setItem('token', data.data.token)
|
||||
localStorage.setItem('user', JSON.stringify(data.data.user))
|
||||
|
||||
message.success('注册成功')
|
||||
setRegisterModalVisible(false)
|
||||
// 使用 window.location 跳转,刷新页面以正确加载首页的聊天插件
|
||||
window.location.href = '/'
|
||||
Toast.show({
|
||||
icon: 'success',
|
||||
content: '注册成功',
|
||||
})
|
||||
|
||||
navigate('/')
|
||||
} else {
|
||||
message.error(data.message || '注册失败')
|
||||
Toast.show({
|
||||
icon: 'fail',
|
||||
content: data.message || '注册失败',
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
message.error('网络错误,请稍后重试')
|
||||
Toast.show({
|
||||
icon: 'fail',
|
||||
content: '网络错误,请稍后重试',
|
||||
})
|
||||
console.error('注册错误:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理用户类型更新
|
||||
const handleUpdateUserType = async (values: { user_type: string }) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/user/type', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(values),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
// 更新本地存储的用户信息
|
||||
const user = JSON.parse(localStorage.getItem('user') || '{}')
|
||||
user.user_type = data.data.user_type
|
||||
localStorage.setItem('user', JSON.stringify(user))
|
||||
|
||||
message.success('身份类型设置成功')
|
||||
setUserTypeModalVisible(false)
|
||||
// 使用 window.location 跳转,刷新页面以正确加载首页的聊天插件
|
||||
window.location.href = '/'
|
||||
} else {
|
||||
message.error(data.message || '更新失败')
|
||||
}
|
||||
} catch (err) {
|
||||
message.error('网络错误,请稍后重试')
|
||||
console.error('更新用户类型错误:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (isLogin) {
|
||||
handleLogin()
|
||||
} else {
|
||||
handleRegister()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.content}>
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.header}>
|
||||
<Title level={2} className={styles.title}>AnKao</Title>
|
||||
<Text type="secondary" className={styles.subtitle}>欢迎使用题库系统</Text>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
form={loginForm}
|
||||
name="login"
|
||||
onFinish={handleLogin}
|
||||
autoComplete="off"
|
||||
size="large"
|
||||
>
|
||||
<Form.Item
|
||||
name="username"
|
||||
rules={[{ required: true, message: '请输入用户名' }]}
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder="请输入用户名"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Checkbox
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
>
|
||||
记住密码
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" block loading={loading}>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
|
||||
<div className={styles.registerTip}>
|
||||
<Text type="secondary">还没有账号?</Text>
|
||||
<Link onClick={() => setRegisterModalVisible(true)}>立即注册</Link>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
<div className={styles.header}>
|
||||
<h1 className={styles.title}>AnKao</h1>
|
||||
<p className={styles.subtitle}>欢迎使用题库系统</p>
|
||||
</div>
|
||||
|
||||
{/* 注册模态框 */}
|
||||
<Modal
|
||||
title="注册新账号"
|
||||
open={registerModalVisible}
|
||||
onCancel={() => {
|
||||
setRegisterModalVisible(false)
|
||||
registerForm.resetFields()
|
||||
}}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form
|
||||
form={registerForm}
|
||||
name="register"
|
||||
onFinish={handleRegister}
|
||||
autoComplete="off"
|
||||
size="large"
|
||||
style={{ marginTop: 24 }}
|
||||
<form onSubmit={handleSubmit} className={styles.form}>
|
||||
<div className={styles.inputGroup}>
|
||||
<label className={styles.label}>用户名</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="请输入用户名"
|
||||
disabled={loading}
|
||||
className={styles.input}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.inputGroup}>
|
||||
<label className={styles.label}>密码</label>
|
||||
<div className={styles.passwordWrapper}>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={isLogin ? '请输入密码' : '请输入密码(至少6位)'}
|
||||
disabled={loading}
|
||||
className={styles.input}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.eyeButton}
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? <EyeOutline /> : <EyeInvisibleOutline />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isLogin && (
|
||||
<div className={styles.inputGroup}>
|
||||
<label className={styles.label}>昵称(可选)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={nickname}
|
||||
onChange={(e) => setNickname(e.target.value)}
|
||||
placeholder="请输入昵称"
|
||||
disabled={loading}
|
||||
className={styles.input}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
block
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
className={styles.submitButton}
|
||||
>
|
||||
<Form.Item
|
||||
name="username"
|
||||
rules={[
|
||||
{ required: true, message: '请输入用户名' },
|
||||
{ min: 3, message: '用户名至少3个字符' }
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder="请输入用户名"
|
||||
/>
|
||||
</Form.Item>
|
||||
{isLogin ? '登录' : '注册'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[
|
||||
{ required: true, message: '请输入密码' },
|
||||
{ min: 6, message: '密码长度至少6位' }
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="请输入密码(至少6位)"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="nickname"
|
||||
rules={[
|
||||
{ required: true, message: '请输入姓名' },
|
||||
{ max: 20, message: '姓名最多20个字符' }
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
prefix={<IdcardOutlined />}
|
||||
placeholder="请输入姓名"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="user_type"
|
||||
label="身份类型"
|
||||
rules={[{ required: true, message: '请选择身份类型' }]}
|
||||
>
|
||||
<Radio.Group>
|
||||
<Radio value="ordinary-person">普通涉密人员</Radio>
|
||||
<Radio value="management-person">保密管理人员</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" block loading={loading}>
|
||||
注册
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 用户类型补充模态框 */}
|
||||
<Modal
|
||||
title="请选择您的身份类型"
|
||||
open={userTypeModalVisible}
|
||||
closable={false}
|
||||
maskClosable={false}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
>
|
||||
<Alert
|
||||
message="论述题需要使用"
|
||||
description="为了更好地为您提供相应的论述题内容,请选择您的身份类型。"
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 24 }}
|
||||
/>
|
||||
<Form
|
||||
form={userTypeForm}
|
||||
name="userType"
|
||||
onFinish={handleUpdateUserType}
|
||||
autoComplete="off"
|
||||
size="large"
|
||||
>
|
||||
<Form.Item
|
||||
name="user_type"
|
||||
label="身份类型"
|
||||
rules={[{ required: true, message: '请选择身份类型' }]}
|
||||
>
|
||||
<Radio.Group>
|
||||
<Radio value="ordinary-person">普通涉密人员</Radio>
|
||||
<Radio value="management-person">保密管理人员</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" block loading={loading}>
|
||||
确认
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
<div className={styles.switchMode}>
|
||||
{isLogin ? (
|
||||
<p>
|
||||
还没有账号?
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsLogin(false)}
|
||||
className={styles.linkBtn}
|
||||
disabled={loading}
|
||||
>
|
||||
立即注册
|
||||
</button>
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
已有账号?
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsLogin(true)}
|
||||
className={styles.linkBtn}
|
||||
disabled={loading}
|
||||
>
|
||||
返回登录
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
42
web/src/pages/Profile.module.less
Normal file
42
web/src/pages/Profile.module.less
Normal file
@ -0,0 +1,42 @@
|
||||
// 变量定义
|
||||
@bg-color: #f5f5f5;
|
||||
@text-secondary: #999;
|
||||
|
||||
// 页面容器
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background-color: @bg-color;
|
||||
padding: 16px;
|
||||
padding-bottom: 70px;
|
||||
}
|
||||
|
||||
// 用户卡片
|
||||
.userCard {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.userInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.userDetails {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.userNickname {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.userUsername {
|
||||
font-size: 14px;
|
||||
color: @text-secondary;
|
||||
}
|
||||
|
||||
// 登出容器
|
||||
.logoutContainer {
|
||||
margin-top: 24px;
|
||||
}
|
||||
130
web/src/pages/Profile.tsx
Normal file
130
web/src/pages/Profile.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Card,
|
||||
Avatar,
|
||||
List,
|
||||
Button,
|
||||
Dialog,
|
||||
Toast,
|
||||
} from 'antd-mobile'
|
||||
import {
|
||||
RightOutline,
|
||||
SetOutline,
|
||||
FileOutline,
|
||||
UserOutline,
|
||||
} from 'antd-mobile-icons'
|
||||
import styles from './Profile.module.less'
|
||||
|
||||
interface UserInfo {
|
||||
username: string
|
||||
nickname: string
|
||||
avatar: string
|
||||
}
|
||||
|
||||
const Profile: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const [userInfo, setUserInfo] = useState<UserInfo | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// 从 localStorage 获取用户信息
|
||||
const token = localStorage.getItem('token')
|
||||
const savedUserInfo = localStorage.getItem('user')
|
||||
|
||||
if (token && savedUserInfo) {
|
||||
try {
|
||||
setUserInfo(JSON.parse(savedUserInfo))
|
||||
} catch (e) {
|
||||
console.error('解析用户信息失败', e)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleLogout = async () => {
|
||||
const result = await Dialog.confirm({
|
||||
content: '确定要退出登录吗?',
|
||||
})
|
||||
|
||||
if (result) {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
setUserInfo(null)
|
||||
Toast.show('已退出登录')
|
||||
navigate('/login')
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogin = () => {
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
{/* 用户信息卡片 */}
|
||||
<Card className={styles.userCard}>
|
||||
{userInfo ? (
|
||||
<div className={styles.userInfo}>
|
||||
<Avatar
|
||||
src={userInfo.avatar || undefined}
|
||||
style={{ '--size': '64px' }}
|
||||
>
|
||||
{!userInfo.avatar && <UserOutline fontSize={32} />}
|
||||
</Avatar>
|
||||
<div className={styles.userDetails}>
|
||||
<div className={styles.userNickname}>{userInfo.nickname}</div>
|
||||
<div className={styles.userUsername}>@{userInfo.username}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.userInfo}>
|
||||
<Avatar style={{ '--size': '64px' }}>
|
||||
<UserOutline fontSize={32} />
|
||||
</Avatar>
|
||||
<div className={styles.userDetails}>
|
||||
<div className={styles.userNickname}>未登录</div>
|
||||
<Button
|
||||
color="primary"
|
||||
size="small"
|
||||
onClick={handleLogin}
|
||||
style={{ marginTop: 8 }}
|
||||
>
|
||||
点击登录
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 功能列表 */}
|
||||
<List header="功能">
|
||||
<List.Item
|
||||
prefix={<FileOutline />}
|
||||
onClick={() => Toast.show('功能开发中')}
|
||||
clickable
|
||||
>
|
||||
我的题目
|
||||
<RightOutline />
|
||||
</List.Item>
|
||||
<List.Item
|
||||
prefix={<SetOutline />}
|
||||
onClick={() => Toast.show('功能开发中')}
|
||||
clickable
|
||||
>
|
||||
设置
|
||||
<RightOutline />
|
||||
</List.Item>
|
||||
</List>
|
||||
|
||||
{/* 退出登录按钮 */}
|
||||
{userInfo && (
|
||||
<div className={styles.logoutContainer}>
|
||||
<Button block color="danger" onClick={handleLogout}>
|
||||
退出登录
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Profile
|
||||
@ -1,201 +1,240 @@
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
background: #fafafa;
|
||||
padding: 0;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
// 变量定义
|
||||
@bg-color: #f5f5f5;
|
||||
@white: #fff;
|
||||
@primary-color: #1677ff;
|
||||
@text-color: #333;
|
||||
@text-secondary: #666;
|
||||
@text-tertiary: #888;
|
||||
@border-color: #e8e8e8;
|
||||
@shadow-light: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
@shadow-medium: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
@success-bg: #f6ffed;
|
||||
@success-border: #b7eb8f;
|
||||
@success-color: #52c41a;
|
||||
@error-bg: #fff2f0;
|
||||
@error-border: #ffccc7;
|
||||
@error-color: #ff4d4f;
|
||||
|
||||
// 固定顶栏
|
||||
.fixedTopBar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
background: rgba(250, 250, 250, 0.85);
|
||||
backdrop-filter: blur(40px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(40px) saturate(180%);
|
||||
}
|
||||
|
||||
.topBarContent {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.topBarCard {
|
||||
// 移除卡片样式
|
||||
}
|
||||
|
||||
.content {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
padding-top: 80px; // 减少顶部空间,因为去掉了进度条
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.backButton {
|
||||
color: #007aff;
|
||||
font-weight: 500;
|
||||
padding: 4px 12px;
|
||||
|
||||
&:hover {
|
||||
color: #0051d5;
|
||||
background: rgba(0, 122, 255, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
color: #1d1d1f !important;
|
||||
margin: 0 !important;
|
||||
font-weight: 700;
|
||||
font-size: 18px !important;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.settingsButton {
|
||||
color: #8c8c8c;
|
||||
font-weight: 500;
|
||||
padding: 4px 12px;
|
||||
|
||||
&:hover {
|
||||
color: #1d1d1f;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.progressWrapper {
|
||||
// 进度条容器
|
||||
}
|
||||
|
||||
.questionCard {
|
||||
// 去掉卡片样式,但添加左右内边距
|
||||
padding: 0 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.questionNumber {
|
||||
color: #6e6e73;
|
||||
margin: 8px 0 16px 0 !important;
|
||||
}
|
||||
|
||||
.questionContent {
|
||||
font-size: 18px;
|
||||
line-height: 1.6;
|
||||
color: #1d1d1f;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.fillInput {
|
||||
border-bottom: 2px solid #007aff !important;
|
||||
border-radius: 0 !important;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.buttonGroup {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
// 响应式设计 - 移动端
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding-bottom: 70px;
|
||||
}
|
||||
|
||||
.topBarContent {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.topBarCard {
|
||||
// 移除卡片样式
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 0 12px;
|
||||
padding-top: 70px; // 移动端减少顶部距离
|
||||
// 使用 :global 包裹所有样式,因为 Question 组件使用直接类名
|
||||
:global {
|
||||
// 页面容器
|
||||
.question-page {
|
||||
min-height: 100vh;
|
||||
background: @bg-color;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
// 头部
|
||||
.header {
|
||||
.backButton {
|
||||
background: @white;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: @shadow-light;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: @primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
// 内容区域
|
||||
.content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
// 题目部分
|
||||
.question-header {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.question-number {
|
||||
font-size: 14px;
|
||||
color: @text-secondary;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.question-content {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: @text-color;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 8px;
|
||||
|
||||
// 填空题样式
|
||||
&.fill-content {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
|
||||
span {
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
.fill-input {
|
||||
:global(.adm-input-element) {
|
||||
border: none;
|
||||
border-bottom: 2px solid @primary-color;
|
||||
border-radius: 0;
|
||||
padding: 4px 8px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
color: @primary-color;
|
||||
|
||||
&::placeholder {
|
||||
color: #bfbfbf;
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 答案结果
|
||||
.answer-result {
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
background: @success-bg;
|
||||
border: 1px solid @success-border;
|
||||
|
||||
&.wrong {
|
||||
background: @error-bg;
|
||||
border: 1px solid @error-border;
|
||||
}
|
||||
|
||||
.result-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&.correct .result-icon {
|
||||
color: @success-color;
|
||||
}
|
||||
|
||||
&.wrong .result-icon {
|
||||
color: @error-color;
|
||||
}
|
||||
|
||||
.result-text {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.correct-answer {
|
||||
font-size: 14px;
|
||||
padding: 4px 8px;
|
||||
color: @text-secondary;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
.settingsButton {
|
||||
.explanation {
|
||||
font-size: 14px;
|
||||
padding: 4px 8px;
|
||||
color: @text-tertiary;
|
||||
line-height: 1.5;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid @border-color;
|
||||
}
|
||||
}
|
||||
|
||||
.questionCard {
|
||||
padding: 0 12px;
|
||||
margin-bottom: 16px;
|
||||
// 按钮组
|
||||
.button-group {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.questionContent {
|
||||
font-size: 16px;
|
||||
}
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
background: @white;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
|
||||
|
||||
.actionButtons {
|
||||
button {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计 - 平板
|
||||
@media (min-width: 769px) and (max-width: 1024px) {
|
||||
.topBarContent {
|
||||
padding: 14px 24px;
|
||||
}
|
||||
// 统计内容
|
||||
.stats-content {
|
||||
padding: 20px;
|
||||
|
||||
.content {
|
||||
padding: 0 24px;
|
||||
padding-top: 75px;
|
||||
}
|
||||
|
||||
.header {
|
||||
.title {
|
||||
font-size: 20px !important;
|
||||
h2 {
|
||||
margin: 0 0 20px 0;
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计 - PC端
|
||||
@media (min-width: 1025px) {
|
||||
.topBarContent {
|
||||
padding: 18px 32px;
|
||||
}
|
||||
.stat-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
.content {
|
||||
padding: 0 32px;
|
||||
padding-top: 85px;
|
||||
}
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.header {
|
||||
.title {
|
||||
font-size: 22px !important;
|
||||
span {
|
||||
font-size: 16px;
|
||||
color: @text-secondary;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 20px;
|
||||
color: @primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
// 筛选内容
|
||||
.filter-content {
|
||||
padding: 20px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 20px 0;
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
margin-bottom: 20px;
|
||||
|
||||
p {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 14px;
|
||||
color: @text-secondary;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
// 覆盖 antd-mobile 样式
|
||||
.adm-card {
|
||||
border-radius: 12px;
|
||||
box-shadow: @shadow-medium;
|
||||
}
|
||||
|
||||
.adm-list-item {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.adm-modal-body {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,342 +0,0 @@
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
background-color: #f5f5f5;
|
||||
padding: 16px;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.backButton {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
width: 64px; // 与返回按钮宽度保持一致
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 !important;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.filterCard {
|
||||
margin-bottom: 12px;
|
||||
|
||||
:global {
|
||||
.ant-card-body {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tabsCard {
|
||||
margin-bottom: 12px;
|
||||
|
||||
:global {
|
||||
.ant-card-body {
|
||||
padding: 12px 16px 16px 16px;
|
||||
}
|
||||
|
||||
.ant-tabs-nav {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.questionCard {
|
||||
margin-bottom: 8px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
:global {
|
||||
.ant-card-body {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.questionHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.questionId {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.questionNumber {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.questionContent {
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.contentText {
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
margin: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
color: #262626;
|
||||
flex: 1;
|
||||
|
||||
:global(.ant-typography) {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.trueFalseAnswer {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.trueFalseTag {
|
||||
font-size: 14px;
|
||||
padding: 4px 12px;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.blankAnswer {
|
||||
display: inline-block;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #52c41a;
|
||||
border-bottom: 2px solid #52c41a;
|
||||
padding: 0 4px 2px 4px;
|
||||
margin: 0 4px;
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.options {
|
||||
background-color: #fafafa;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
|
||||
&:not(:last-child) {
|
||||
padding-bottom: 8px;
|
||||
margin-bottom: 8px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
}
|
||||
|
||||
.correctOption {
|
||||
background-color: #f6ffed;
|
||||
padding: 8px;
|
||||
margin: 0 -12px;
|
||||
padding-left: 11px; // 精确补偿:12(容器) - 12(margin) + 1(border) + 11 = 12px,与普通选项对齐
|
||||
padding-right: 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #b7eb8f;
|
||||
border-bottom: 1px solid #b7eb8f !important;
|
||||
|
||||
&:not(:last-child) {
|
||||
// 保持与父元素的 border-bottom 和 margin-bottom
|
||||
}
|
||||
}
|
||||
|
||||
.correctTag {
|
||||
margin-left: auto;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.answerSection {
|
||||
background-color: #f6ffed;
|
||||
border: 1px solid #b7eb8f;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.answerText {
|
||||
margin: 6px 0 0 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
font-size: 14px;
|
||||
color: #262626;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
|
||||
:global(.ant-typography) {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 悬浮按钮组样式美化
|
||||
:global {
|
||||
.ant-float-btn-group {
|
||||
.ant-float-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.18);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.ant-float-btn-body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.ant-float-btn-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
// 置顶按钮样式
|
||||
&:not(.ant-float-btn-primary) {
|
||||
background: white;
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #e8ecf1 100%);
|
||||
}
|
||||
}
|
||||
|
||||
// 主按钮样式(题目导航)
|
||||
&.ant-float-btn-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(135deg, #5568d3 0%, #6a3f8f 100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 10px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.filterCard {
|
||||
margin-bottom: 10px;
|
||||
|
||||
:global {
|
||||
.ant-card-body {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tabsCard {
|
||||
margin-bottom: 10px;
|
||||
|
||||
:global {
|
||||
.ant-card-body {
|
||||
padding: 10px 12px 12px 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.questionCard {
|
||||
margin-bottom: 8px;
|
||||
|
||||
:global {
|
||||
.ant-card-body {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.questionHeader {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.questionNumber {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.questionContent {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.trueFalseAnswer {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.options {
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.option {
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.correctOption {
|
||||
margin: 0 -10px; // 匹配移动端容器的padding
|
||||
padding-left: 9px; // 精确补偿:10(容器) - 10(margin) + 1(border) + 9 = 10px
|
||||
}
|
||||
|
||||
.answerSection {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
// 移动端悬浮按钮适配
|
||||
:global {
|
||||
.ant-float-btn-group {
|
||||
right: 16px !important;
|
||||
bottom: 76px !important; // 避开底部导航栏
|
||||
|
||||
.ant-float-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
|
||||
.ant-float-btn-body .ant-float-btn-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,384 +0,0 @@
|
||||
import React, { useEffect, useState, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Card,
|
||||
Tabs,
|
||||
List,
|
||||
Tag,
|
||||
Typography,
|
||||
Space,
|
||||
Divider,
|
||||
Button,
|
||||
Spin,
|
||||
message,
|
||||
Input,
|
||||
FloatButton,
|
||||
} from 'antd'
|
||||
import {
|
||||
BookOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
EditOutlined,
|
||||
FileTextOutlined,
|
||||
UnorderedListOutlined,
|
||||
SearchOutlined,
|
||||
ArrowLeftOutlined,
|
||||
VerticalAlignTopOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import * as questionApi from '../api/question'
|
||||
import type { Question } from '../types/question'
|
||||
import QuestionListDrawer from '../components/QuestionListDrawer'
|
||||
import styles from './QuestionList.module.less'
|
||||
|
||||
const { Title, Text, Paragraph } = Typography
|
||||
|
||||
// 题型配置
|
||||
const questionTypeConfig: Record<string, { label: string; icon: React.ReactNode; color: string }> = {
|
||||
'multiple-choice': { label: '选择题', icon: <CheckCircleOutlined />, color: '#1677ff' },
|
||||
'multiple-selection': { label: '多选题', icon: <UnorderedListOutlined />, color: '#52c41a' },
|
||||
'true-false': { label: '判断题', icon: <CheckCircleOutlined />, color: '#fa8c16' },
|
||||
'fill-in-blank': { label: '填空题', icon: <FileTextOutlined />, color: '#722ed1' },
|
||||
'short-answer': { label: '简答题', icon: <EditOutlined />, color: '#eb2f96' },
|
||||
'ordinary-essay': { label: '普通涉密人员论述题', icon: <FileTextOutlined />, color: '#f759ab' },
|
||||
'management-essay': { label: '保密管理人员论述题', icon: <FileTextOutlined />, color: '#d4380d' },
|
||||
}
|
||||
|
||||
const QuestionList: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [questions, setQuestions] = useState<Question[]>([])
|
||||
const [filteredQuestions, setFilteredQuestions] = useState<Question[]>([])
|
||||
const [selectedType, setSelectedType] = useState<string>('all')
|
||||
const [searchKeyword, setSearchKeyword] = useState<string>('')
|
||||
const [drawerVisible, setDrawerVisible] = useState(false)
|
||||
|
||||
// 用于存储每个题目卡片的ref
|
||||
const questionRefs = useRef<Map<number, HTMLDivElement>>(new Map())
|
||||
|
||||
// 加载题目列表
|
||||
const loadQuestions = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await questionApi.getQuestions({})
|
||||
if (res.success && res.data) {
|
||||
setQuestions(res.data)
|
||||
setFilteredQuestions(res.data)
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('加载题目列表失败')
|
||||
console.error('加载题目失败:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadQuestions()
|
||||
}, [])
|
||||
|
||||
// 筛选题目
|
||||
useEffect(() => {
|
||||
let filtered = questions
|
||||
|
||||
// 按题型筛选
|
||||
if (selectedType !== 'all') {
|
||||
filtered = filtered.filter(q => q.type === selectedType)
|
||||
}
|
||||
|
||||
// 按关键词搜索
|
||||
if (searchKeyword.trim()) {
|
||||
const keyword = searchKeyword.trim().toLowerCase()
|
||||
filtered = filtered.filter(q =>
|
||||
q.content.toLowerCase().includes(keyword) ||
|
||||
q.question_id.toLowerCase().includes(keyword)
|
||||
)
|
||||
}
|
||||
|
||||
setFilteredQuestions(filtered)
|
||||
}, [selectedType, searchKeyword, questions])
|
||||
|
||||
// 渲染填空题内容,将****替换为答案(带下划线样式)
|
||||
const renderFillInBlankContent = (content: string, answers: string[]): React.ReactNode => {
|
||||
// 将 **** 替换为答案
|
||||
const parts = content.split('****')
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{part}
|
||||
{index < parts.length - 1 && (
|
||||
<span className={styles.blankAnswer}>{answers[index] || '______'}</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// 格式化答案显示
|
||||
const formatAnswer = (question: Question): string => {
|
||||
const { type, answer, options } = question
|
||||
|
||||
switch (type) {
|
||||
case 'true-false':
|
||||
return answer === true ? '正确' : '错误'
|
||||
|
||||
case 'multiple-choice':
|
||||
const option = options?.find(opt => opt.key === answer)
|
||||
return option ? `${answer}. ${option.value}` : String(answer)
|
||||
|
||||
case 'multiple-selection':
|
||||
if (Array.isArray(answer)) {
|
||||
return answer.map(key => {
|
||||
const opt = options?.find(o => o.key === key)
|
||||
return opt ? `${key}. ${opt.value}` : key
|
||||
}).join('; ')
|
||||
}
|
||||
return String(answer)
|
||||
|
||||
case 'fill-in-blank':
|
||||
if (Array.isArray(answer)) {
|
||||
return answer.map((a, i) => `空${i + 1}: ${a}`).join('; ')
|
||||
}
|
||||
return String(answer)
|
||||
|
||||
case 'short-answer':
|
||||
return String(answer)
|
||||
|
||||
default:
|
||||
return String(answer)
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染选项(选择题和多选题)
|
||||
const renderOptions = (question: Question) => {
|
||||
// 判断题不显示选项
|
||||
if (question.type === 'true-false') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!question.options || question.options.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 判断选项是否为正确答案
|
||||
const isCorrectOption = (key: string): boolean => {
|
||||
if (question.type === 'multiple-selection' && Array.isArray(question.answer)) {
|
||||
return question.answer.includes(key)
|
||||
}
|
||||
return question.answer === key
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.options}>
|
||||
{question.options.map(opt => {
|
||||
const isCorrect = isCorrectOption(opt.key)
|
||||
return (
|
||||
<div
|
||||
key={opt.key}
|
||||
className={`${styles.option} ${isCorrect ? styles.correctOption : ''}`}
|
||||
>
|
||||
<Tag color={isCorrect ? 'success' : 'blue'}>{opt.key}</Tag>
|
||||
<Text>{opt.value}</Text>
|
||||
{isCorrect && (
|
||||
<Tag color="success" className={styles.correctTag}>
|
||||
<CheckCircleOutlined /> 正确答案
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 滚动到指定题目
|
||||
const handleQuestionSelect = (index: number) => {
|
||||
const questionCard = questionRefs.current.get(index)
|
||||
if (questionCard) {
|
||||
questionCard.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
})
|
||||
// 高亮效果
|
||||
questionCard.style.transition = 'all 0.3s ease'
|
||||
questionCard.style.transform = 'scale(1.02)'
|
||||
questionCard.style.boxShadow = '0 8px 24px rgba(102, 126, 234, 0.3)'
|
||||
setTimeout(() => {
|
||||
questionCard.style.transform = 'scale(1)'
|
||||
questionCard.style.boxShadow = ''
|
||||
}, 600)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* 头部 */}
|
||||
<div className={styles.header}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/')}
|
||||
className={styles.backButton}
|
||||
>
|
||||
返回
|
||||
</Button>
|
||||
<Title level={5} className={styles.title}>
|
||||
<BookOutlined /> 题目列表
|
||||
</Title>
|
||||
{/* 占位元素,保持标题居中 */}
|
||||
<div className={styles.placeholder}></div>
|
||||
</div>
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<Card className={styles.filterCard}>
|
||||
<Input
|
||||
placeholder="搜索题目内容或题目编号"
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchKeyword}
|
||||
onChange={e => setSearchKeyword(e.target.value)}
|
||||
allowClear
|
||||
size="large"
|
||||
className={styles.searchInput}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 题型选项卡 */}
|
||||
<Card className={styles.tabsCard}>
|
||||
<Tabs
|
||||
activeKey={selectedType}
|
||||
onChange={setSelectedType}
|
||||
items={[
|
||||
{
|
||||
key: 'all',
|
||||
label: (
|
||||
<span>
|
||||
<BookOutlined /> 全部题型
|
||||
</span>
|
||||
),
|
||||
},
|
||||
...Object.entries(questionTypeConfig).map(([type, config]) => ({
|
||||
key: type,
|
||||
label: (
|
||||
<span>
|
||||
{config.icon} {config.label}
|
||||
</span>
|
||||
),
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
<Divider style={{ margin: '12px 0 0 0' }} />
|
||||
<Text type="secondary">
|
||||
共 {filteredQuestions.length} 道题目
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
{/* 题目列表 */}
|
||||
<Spin spinning={loading}>
|
||||
<List
|
||||
dataSource={filteredQuestions}
|
||||
renderItem={(question, index) => {
|
||||
return (
|
||||
<Card
|
||||
key={question.id}
|
||||
className={styles.questionCard}
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
questionRefs.current.set(index, el)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* 题目头部 */}
|
||||
<div className={styles.questionHeader}>
|
||||
<Space size="small">
|
||||
<Text type="secondary" className={styles.questionNumber}>
|
||||
第 {index + 1} 题
|
||||
</Text>
|
||||
<Tag color="blue">{question.category}</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 题目内容 */}
|
||||
<div className={styles.questionContent}>
|
||||
<Paragraph className={styles.contentText}>
|
||||
{question.type === 'fill-in-blank'
|
||||
? renderFillInBlankContent(
|
||||
question.content,
|
||||
Array.isArray(question.answer) ? question.answer : []
|
||||
)
|
||||
: question.content
|
||||
}
|
||||
</Paragraph>
|
||||
|
||||
{/* 判断题:在题目右侧显示答案标签 */}
|
||||
{question.type === 'true-false' && (
|
||||
<div className={styles.trueFalseAnswer}>
|
||||
<Tag
|
||||
color={question.answer === true ? 'success' : 'error'}
|
||||
className={styles.trueFalseTag}
|
||||
>
|
||||
{question.answer === true ? <CheckCircleOutlined /> : <CloseCircleOutlined />} {question.answer === true ? '正确' : '错误'}
|
||||
</Tag>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 选项 */}
|
||||
{renderOptions(question)}
|
||||
|
||||
{/* 答案区域 */}
|
||||
{/* 填空题:答案已经在横线上方,不需要显示 */}
|
||||
{/* 选择题和多选题:答案已经标注在选项上,不需要显示 */}
|
||||
{/* 判断题:答案已经在题目右侧,不需要显示 */}
|
||||
{/* 论述题:不展示答案 */}
|
||||
{/* 简答题:需要显示答案 */}
|
||||
{question.type === 'short-answer' && (
|
||||
<div className={styles.answerSection}>
|
||||
<Space size="small">
|
||||
<CheckCircleOutlined style={{ color: '#52c41a' }} />
|
||||
<Text strong>参考答案:</Text>
|
||||
</Space>
|
||||
<Paragraph className={styles.answerText}>
|
||||
{formatAnswer(question)}
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}}
|
||||
locale={{ emptyText: '暂无题目' }}
|
||||
/>
|
||||
</Spin>
|
||||
|
||||
{/* 题目导航抽屉 */}
|
||||
<QuestionListDrawer
|
||||
visible={drawerVisible}
|
||||
onClose={() => setDrawerVisible(false)}
|
||||
questions={filteredQuestions}
|
||||
onQuestionSelect={handleQuestionSelect}
|
||||
/>
|
||||
|
||||
{/* 悬浮按钮组 */}
|
||||
{filteredQuestions.length > 0 && (
|
||||
<FloatButton.Group
|
||||
shape="circle"
|
||||
style={{ right: 20, bottom: 20 }}
|
||||
>
|
||||
<FloatButton
|
||||
icon={<VerticalAlignTopOutlined />}
|
||||
tooltip="返回顶部"
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||
/>
|
||||
<FloatButton
|
||||
icon={<UnorderedListOutlined />}
|
||||
type="primary"
|
||||
tooltip="题目导航"
|
||||
onClick={() => setDrawerVisible(true)}
|
||||
/>
|
||||
</FloatButton.Group>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default QuestionList
|
||||
@ -1,61 +0,0 @@
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
background-color: #f5f5f5;
|
||||
padding: 16px;
|
||||
|
||||
@media (min-width: 1025px) {
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 16px;
|
||||
|
||||
.headerContent {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
:global {
|
||||
.ant-table-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.header {
|
||||
.headerContent {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
:global {
|
||||
.ant-table {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,704 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
message,
|
||||
Popconfirm,
|
||||
Tag,
|
||||
Card,
|
||||
Radio,
|
||||
Divider,
|
||||
} from 'antd'
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
ArrowLeftOutlined,
|
||||
MinusCircleOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import * as questionApi from '../api/question'
|
||||
import type { Question } from '../types/question'
|
||||
import styles from './QuestionManagement.module.less'
|
||||
|
||||
const { Option } = Select
|
||||
const { TextArea } = Input
|
||||
|
||||
// 题型配置
|
||||
const questionTypes = [
|
||||
{ key: 'multiple-choice', label: '单选题' },
|
||||
{ key: 'multiple-selection', label: '多选题' },
|
||||
{ key: 'true-false', label: '判断题' },
|
||||
{ key: 'fill-in-blank', label: '填空题' },
|
||||
{ key: 'short-answer', label: '简答题' },
|
||||
{ key: 'ordinary-essay', label: '普通涉密人员论述题' },
|
||||
{ key: 'management-essay', label: '保密管理人员论述题' },
|
||||
]
|
||||
|
||||
const QuestionManagement: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const [questions, setQuestions] = useState<Question[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
const [editingQuestion, setEditingQuestion] = useState<Question | null>(null)
|
||||
const [form] = Form.useForm()
|
||||
|
||||
// 筛选和搜索状态
|
||||
const [selectedType, setSelectedType] = useState<string>('')
|
||||
const [searchText, setSearchText] = useState<string>('')
|
||||
|
||||
// 加载题目列表
|
||||
const loadQuestions = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params: any = {}
|
||||
if (selectedType) {
|
||||
params.type = selectedType
|
||||
}
|
||||
if (searchText) {
|
||||
params.search = searchText
|
||||
}
|
||||
const res = await questionApi.getQuestions(params)
|
||||
if (res.success && res.data) {
|
||||
setQuestions(res.data)
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('加载题目失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadQuestions()
|
||||
}, [selectedType, searchText])
|
||||
|
||||
// 打开新建/编辑弹窗
|
||||
const handleOpenModal = (question?: Question) => {
|
||||
if (question) {
|
||||
setEditingQuestion(question)
|
||||
|
||||
// 直接使用后端返回的答案数据
|
||||
let answerValue: any = question.answer
|
||||
|
||||
// 解析选项(单选题和多选题)
|
||||
let optionsValue: Array<{ key: string; value: string }> = []
|
||||
if (question.options && question.options.length > 0 &&
|
||||
(question.type === 'multiple-choice' || question.type === 'multiple-selection')) {
|
||||
optionsValue = question.options.map(opt => ({
|
||||
key: opt.key,
|
||||
value: opt.value,
|
||||
}))
|
||||
}
|
||||
|
||||
// 设置表单值
|
||||
form.setFieldsValue({
|
||||
type: question.type,
|
||||
content: question.content,
|
||||
answer: answerValue,
|
||||
options: optionsValue,
|
||||
})
|
||||
} else {
|
||||
setEditingQuestion(null)
|
||||
form.resetFields()
|
||||
|
||||
// 新建时设置默认值 - 参照编辑逻辑
|
||||
const defaultType = 'multiple-choice'
|
||||
|
||||
// 设置默认答案值
|
||||
let defaultAnswer: any = ''
|
||||
let defaultOptions: Array<{ key: string; value: string }> = []
|
||||
|
||||
// 为单选和多选题设置默认选项
|
||||
if (defaultType === 'multiple-choice' || defaultType === 'multiple-selection') {
|
||||
defaultOptions = [{ key: 'A', value: '' }, { key: 'B', value: '' }]
|
||||
}
|
||||
|
||||
form.setFieldsValue({
|
||||
type: defaultType,
|
||||
content: '',
|
||||
answer: defaultAnswer,
|
||||
options: defaultOptions,
|
||||
})
|
||||
}
|
||||
setModalVisible(true)
|
||||
}
|
||||
|
||||
// 关闭弹窗
|
||||
const handleCloseModal = () => {
|
||||
setModalVisible(false)
|
||||
setEditingQuestion(null)
|
||||
form.resetFields()
|
||||
}
|
||||
|
||||
// 保存题目
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
|
||||
// 解析答案
|
||||
let answer: any
|
||||
if (values.type === 'true-false') {
|
||||
answer = values.answer
|
||||
} else if (values.type === 'multiple-choice') {
|
||||
answer = values.answer
|
||||
} else if (values.type === 'multiple-selection') {
|
||||
// 多选题答案是数组
|
||||
answer = values.answer
|
||||
} else if (values.type === 'fill-in-blank') {
|
||||
// 填空题答案是数组
|
||||
answer = values.answer
|
||||
} else if (values.type === 'short-answer' || values.type === 'ordinary-essay' || values.type === 'management-essay') {
|
||||
answer = values.answer
|
||||
} else {
|
||||
answer = values.answer
|
||||
}
|
||||
|
||||
// 解析选项(仅选择题和多选题需要)
|
||||
let options: Record<string, string> | undefined
|
||||
if (values.type === 'multiple-choice' || values.type === 'multiple-selection') {
|
||||
if (values.options && values.options.length > 0) {
|
||||
// 将数组格式转换为对象格式 { "A": "选项A", "B": "选项B" }
|
||||
options = values.options.reduce((acc: Record<string, string>, opt: any) => {
|
||||
if (opt && opt.key && opt.value) {
|
||||
acc[opt.key] = opt.value
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
}
|
||||
|
||||
// 构建请求数据
|
||||
const data = {
|
||||
type: values.type,
|
||||
type_name: '', // 不再使用分类字段
|
||||
question: values.content,
|
||||
answer: answer,
|
||||
options: options,
|
||||
}
|
||||
|
||||
if (editingQuestion) {
|
||||
// 更新
|
||||
await questionApi.updateQuestion(editingQuestion.id, data)
|
||||
message.success('更新成功')
|
||||
} else {
|
||||
// 创建
|
||||
await questionApi.createQuestion(data)
|
||||
message.success('创建成功')
|
||||
}
|
||||
|
||||
handleCloseModal()
|
||||
loadQuestions()
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
message.error('保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 删除题目
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await questionApi.deleteQuestion(id)
|
||||
message.success('删除成功')
|
||||
loadQuestions()
|
||||
} catch (error) {
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染填空题题目内容:将 **** 替换为带下划线的正确答案
|
||||
const renderFillInBlankContent = (content: string, answer: string[] | any): React.ReactNode => {
|
||||
// 确保答案是数组
|
||||
const answers: string[] = Array.isArray(answer) ? answer : []
|
||||
|
||||
if (answers.length === 0) {
|
||||
return content
|
||||
}
|
||||
|
||||
// 找到所有的 **** 并替换为对应的答案
|
||||
let answerIndex = 0
|
||||
const parts = content.split('****')
|
||||
|
||||
return (
|
||||
<span>
|
||||
{parts.map((part, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{part}
|
||||
{index < parts.length - 1 && (
|
||||
<span style={{
|
||||
textDecoration: 'underline',
|
||||
color: '#1890ff',
|
||||
fontWeight: 500,
|
||||
padding: '0 4px'
|
||||
}}>
|
||||
{answers[answerIndex++] || '____'}
|
||||
</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: '题目编号',
|
||||
dataIndex: 'question_id',
|
||||
key: 'question_id',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '题型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
width: 180,
|
||||
render: (type: string) => {
|
||||
const typeConfig = questionTypes.find(t => t.key === type)
|
||||
const colorMap: Record<string, string> = {
|
||||
'multiple-choice': 'blue',
|
||||
'multiple-selection': 'green',
|
||||
'true-false': 'orange',
|
||||
'fill-in-blank': 'purple',
|
||||
'short-answer': 'magenta',
|
||||
'ordinary-essay': 'pink',
|
||||
'management-essay': 'red',
|
||||
}
|
||||
return <Tag color={colorMap[type]}>{typeConfig?.label || type}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '题目内容',
|
||||
dataIndex: 'content',
|
||||
key: 'content',
|
||||
ellipsis: true,
|
||||
render: (content: string, record: Question) => {
|
||||
// 如果是填空题,使用特殊渲染
|
||||
if (record.type === 'fill-in-blank') {
|
||||
return renderFillInBlankContent(content, record.answer)
|
||||
}
|
||||
return content
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 150,
|
||||
render: (_: any, record: Question) => (
|
||||
<Space size="small">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleOpenModal(record)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定删除这道题目吗?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
// 根据题型动态渲染表单项
|
||||
const renderFormByType = () => {
|
||||
const type = form.getFieldValue('type')
|
||||
|
||||
switch (type) {
|
||||
case 'true-false':
|
||||
return (
|
||||
<Form.Item
|
||||
label="正确答案"
|
||||
name="answer"
|
||||
rules={[{ required: true, message: '请选择答案' }]}
|
||||
>
|
||||
<Radio.Group>
|
||||
<Radio value="true">正确</Radio>
|
||||
<Radio value="false">错误</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
)
|
||||
|
||||
case 'multiple-choice':
|
||||
return (
|
||||
<>
|
||||
<Form.List
|
||||
name="options"
|
||||
rules={[
|
||||
{
|
||||
validator: async (_, options) => {
|
||||
if (!options || options.length < 2) {
|
||||
return Promise.reject(new Error('至少需要2个选项'))
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
{(fields, { add, remove }, { errors }) => (
|
||||
<>
|
||||
<Form.Item label="选项" required>
|
||||
{fields.map((field) => (
|
||||
<Space key={field.key} style={{ display: 'flex', marginBottom: 8 }} align="baseline">
|
||||
<Form.Item
|
||||
{...field}
|
||||
name={[field.name, 'key']}
|
||||
rules={[{ required: true, message: '请输入选项键' }]}
|
||||
noStyle
|
||||
>
|
||||
<Input placeholder="A" style={{ width: 60 }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{...field}
|
||||
name={[field.name, 'value']}
|
||||
rules={[{ required: true, message: '请输入选项内容' }]}
|
||||
noStyle
|
||||
>
|
||||
<Input placeholder="选项内容" style={{ width: 400 }} />
|
||||
</Form.Item>
|
||||
{fields.length > 2 && (
|
||||
<MinusCircleOutlined onClick={() => remove(field.name)} />
|
||||
)}
|
||||
</Space>
|
||||
))}
|
||||
<Form.ErrorList errors={errors} />
|
||||
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
|
||||
添加选项
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
<Form.Item noStyle shouldUpdate={(prevValues, currentValues) => prevValues.options !== currentValues.options}>
|
||||
{({ getFieldValue }) => {
|
||||
const options = getFieldValue('options') || []
|
||||
const optionsList = options
|
||||
.filter((opt: any) => opt && opt.key)
|
||||
.map((opt: any) => ({
|
||||
label: `${opt.key}. ${opt.value || '(请先填写选项内容)'}`,
|
||||
value: opt.key,
|
||||
}))
|
||||
return (
|
||||
<Form.Item
|
||||
label="正确答案"
|
||||
name="answer"
|
||||
rules={[{ required: true, message: '请选择答案' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="选择正确答案"
|
||||
options={optionsList}
|
||||
/>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
</Form.Item>
|
||||
</>
|
||||
)
|
||||
|
||||
case 'multiple-selection':
|
||||
return (
|
||||
<>
|
||||
<Form.List
|
||||
name="options"
|
||||
rules={[
|
||||
{
|
||||
validator: async (_, options) => {
|
||||
if (!options || options.length < 2) {
|
||||
return Promise.reject(new Error('至少需要2个选项'))
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
{(fields, { add, remove }, { errors }) => (
|
||||
<>
|
||||
<Form.Item label="选项" required>
|
||||
{fields.map((field) => (
|
||||
<Space key={field.key} style={{ display: 'flex', marginBottom: 8 }} align="baseline">
|
||||
<Form.Item
|
||||
{...field}
|
||||
name={[field.name, 'key']}
|
||||
rules={[{ required: true, message: '请输入选项键' }]}
|
||||
noStyle
|
||||
>
|
||||
<Input placeholder="A" style={{ width: 60 }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{...field}
|
||||
name={[field.name, 'value']}
|
||||
rules={[{ required: true, message: '请输入选项内容' }]}
|
||||
noStyle
|
||||
>
|
||||
<Input placeholder="选项内容" style={{ width: 400 }} />
|
||||
</Form.Item>
|
||||
{fields.length > 2 && (
|
||||
<MinusCircleOutlined onClick={() => remove(field.name)} />
|
||||
)}
|
||||
</Space>
|
||||
))}
|
||||
<Form.ErrorList errors={errors} />
|
||||
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
|
||||
添加选项
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
<Form.Item noStyle shouldUpdate={(prevValues, currentValues) => prevValues.options !== currentValues.options}>
|
||||
{({ getFieldValue }) => {
|
||||
const options = getFieldValue('options') || []
|
||||
const optionsList = options
|
||||
.filter((opt: any) => opt && opt.key)
|
||||
.map((opt: any) => ({
|
||||
label: `${opt.key}. ${opt.value || '(请先填写选项内容)'}`,
|
||||
value: opt.key,
|
||||
}))
|
||||
return (
|
||||
<Form.Item
|
||||
label="正确答案"
|
||||
name="answer"
|
||||
rules={[{ required: true, message: '请选择答案', type: 'array' }]}
|
||||
tooltip="可以选择多个正确答案"
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="选择正确答案(可多选)"
|
||||
options={optionsList}
|
||||
allowClear
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
String(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
</Form.Item>
|
||||
</>
|
||||
)
|
||||
|
||||
case 'fill-in-blank':
|
||||
return (
|
||||
<Form.List
|
||||
name="answer"
|
||||
rules={[
|
||||
{
|
||||
validator: async (_, answers) => {
|
||||
if (!answers || answers.length < 1) {
|
||||
return Promise.reject(new Error('至少需要1个答案'))
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
{(fields, { add, remove }, { errors }) => (
|
||||
<>
|
||||
<Form.Item label="正确答案" required>
|
||||
{fields.map((field, index) => (
|
||||
<Space key={field.key} style={{ display: 'flex', marginBottom: 8 }} align="baseline">
|
||||
<span>第{['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'][index] || (index + 1)}空:</span>
|
||||
<Form.Item
|
||||
{...field}
|
||||
rules={[{ required: true, message: '请输入答案' }]}
|
||||
noStyle
|
||||
>
|
||||
<Input placeholder="填空答案" style={{ width: 400 }} />
|
||||
</Form.Item>
|
||||
{fields.length > 1 && (
|
||||
<MinusCircleOutlined onClick={() => remove(field.name)} />
|
||||
)}
|
||||
</Space>
|
||||
))}
|
||||
<Form.ErrorList errors={errors} />
|
||||
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
|
||||
添加空格
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
)
|
||||
|
||||
case 'short-answer':
|
||||
case 'ordinary-essay':
|
||||
case 'management-essay':
|
||||
return (
|
||||
<Form.Item
|
||||
label="参考答案"
|
||||
name="answer"
|
||||
rules={[{ required: true, message: '请输入答案' }]}
|
||||
>
|
||||
<TextArea placeholder="输入参考答案" rows={4} />
|
||||
</Form.Item>
|
||||
)
|
||||
|
||||
default:
|
||||
return (
|
||||
<Form.Item
|
||||
label="答案"
|
||||
name="answer"
|
||||
rules={[{ required: true, message: '请输入答案' }]}
|
||||
>
|
||||
<Input placeholder="输入答案" />
|
||||
</Form.Item>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* 头部 */}
|
||||
<Card className={styles.header}>
|
||||
<div className={styles.headerContent}>
|
||||
<Space>
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
返回首页
|
||||
</Button>
|
||||
<Divider type="vertical" />
|
||||
<h2 className={styles.title}>题库管理</h2>
|
||||
</Space>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => handleOpenModal()}
|
||||
>
|
||||
新建题目
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 题目列表 */}
|
||||
<Card className={styles.content}>
|
||||
{/* 筛选和搜索 */}
|
||||
<Space style={{ marginBottom: 16 }} size="middle">
|
||||
<span>题型筛选:</span>
|
||||
<Select
|
||||
style={{ width: 150 }}
|
||||
placeholder="全部题型"
|
||||
allowClear
|
||||
value={selectedType || undefined}
|
||||
onChange={(value) => setSelectedType(value || '')}
|
||||
>
|
||||
{questionTypes.map(type => (
|
||||
<Option key={type.key} value={type.key}>
|
||||
{type.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Input.Search
|
||||
placeholder="搜索题目内容或编号"
|
||||
style={{ width: 300 }}
|
||||
allowClear
|
||||
onSearch={(value) => setSearchText(value)}
|
||||
onChange={(e) => {
|
||||
if (!e.target.value) {
|
||||
setSearchText('')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={questions}
|
||||
loading={loading}
|
||||
rowKey="id"
|
||||
pagination={{
|
||||
defaultPageSize: 20,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 道题目`,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 新建/编辑弹窗 */}
|
||||
<Modal
|
||||
title={editingQuestion ? '编辑题目' : '新建题目'}
|
||||
open={modalVisible}
|
||||
onOk={handleSave}
|
||||
onCancel={handleCloseModal}
|
||||
width={700}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
type: 'multiple-choice',
|
||||
options: [{ key: 'A', value: '' }, { key: 'B', value: '' }],
|
||||
answer: '', // 添加默认answer字段
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
label="题型"
|
||||
name="type"
|
||||
rules={[{ required: true, message: '请选择题型' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="选择题型"
|
||||
onChange={(value) => {
|
||||
// 切换题型时重置答案和选项
|
||||
form.setFieldsValue({ answer: undefined, options: undefined })
|
||||
// 为单选和多选题设置默认选项
|
||||
if (value === 'multiple-choice' || value === 'multiple-selection') {
|
||||
form.setFieldsValue({
|
||||
options: [{ key: 'A', value: '' }, { key: 'B', value: '' }],
|
||||
answer: value === 'multiple-choice' ? '' : [], // 单选空字符串,多选空数组
|
||||
})
|
||||
}
|
||||
// 为判断题设置默认答案
|
||||
else if (value === 'true-false') {
|
||||
form.setFieldsValue({ answer: 'true' })
|
||||
}
|
||||
// 为填空题设置默认答案数组
|
||||
else if (value === 'fill-in-blank') {
|
||||
form.setFieldsValue({ answer: [''] })
|
||||
}
|
||||
// 为简答题和论述题设置默认空字符串
|
||||
else if (value === 'short-answer' || value === 'ordinary-essay' || value === 'management-essay') {
|
||||
form.setFieldsValue({ answer: '' })
|
||||
}
|
||||
}}
|
||||
>
|
||||
{questionTypes.map(type => (
|
||||
<Option key={type.key} value={type.key}>
|
||||
{type.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="题目内容"
|
||||
name="content"
|
||||
rules={[{ required: true, message: '请输入题目内容' }]}
|
||||
>
|
||||
<TextArea rows={4} placeholder="输入题目内容" />
|
||||
</Form.Item>
|
||||
|
||||
{renderFormByType()}
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default QuestionManagement
|
||||
@ -1,92 +0,0 @@
|
||||
.container {
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.loadingContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.backButton {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.userInfoCard {
|
||||
margin-bottom: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.userHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding-bottom: 24px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
border: 3px solid #f0f0f0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.userBasicInfo {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.statCard {
|
||||
text-align: center;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
.statCardContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.detailCard {
|
||||
margin-bottom: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.typeStatsCard {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.userHeader {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.userBasicInfo {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.backButton {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@ -1,290 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Typography,
|
||||
message,
|
||||
Tag,
|
||||
Row,
|
||||
Col,
|
||||
Avatar,
|
||||
Descriptions,
|
||||
Progress,
|
||||
Table,
|
||||
Spin,
|
||||
} from 'antd'
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
UserOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import * as questionApi from '../api/question'
|
||||
import type { UserDetailStats } from '../api/question'
|
||||
import styles from './UserDetail.module.less'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
const UserDetail: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [userDetail, setUserDetail] = useState<UserDetailStats | null>(null)
|
||||
|
||||
// 加载用户详情
|
||||
const loadUserDetail = async () => {
|
||||
if (!id) return
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await questionApi.getUserDetailStats(Number(id))
|
||||
if (res.success && res.data) {
|
||||
setUserDetail(res.data)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('加载用户详情失败:', error)
|
||||
if (error.response?.status === 403) {
|
||||
message.error('无权访问')
|
||||
navigate('/user-management')
|
||||
} else if (error.response?.status === 401) {
|
||||
message.error('请先登录')
|
||||
navigate('/login')
|
||||
} else {
|
||||
message.error('加载用户详情失败')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadUserDetail()
|
||||
}, [id])
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateStr?: string) => {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
// 获取用户类型显示文本
|
||||
const getUserTypeText = (type?: string) => {
|
||||
if (!type) return '未设置'
|
||||
return type === 'ordinary-person' ? '普通涉密人员' : '保密管理人员'
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.loadingContainer}>
|
||||
<Spin size="large" tip="加载中..." />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!userDetail) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { user_info, type_stats } = userDetail
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* 返回按钮 */}
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/user-management')}
|
||||
className={styles.backButton}
|
||||
>
|
||||
返回用户列表
|
||||
</Button>
|
||||
|
||||
{/* 用户信息卡片 */}
|
||||
<Card className={styles.userInfoCard}>
|
||||
<div className={styles.userHeader}>
|
||||
<Avatar
|
||||
size={80}
|
||||
src={user_info.avatar || undefined}
|
||||
icon={<UserOutlined />}
|
||||
className={styles.avatar}
|
||||
/>
|
||||
<div className={styles.userBasicInfo}>
|
||||
<Title level={3} style={{ margin: 0 }}>
|
||||
{user_info.nickname || user_info.username}
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 16 }}>
|
||||
@{user_info.username}
|
||||
</Text>
|
||||
<Tag
|
||||
color={user_info.user_type === 'ordinary-person' ? 'blue' : 'green'}
|
||||
style={{ marginTop: 8 }}
|
||||
>
|
||||
{getUserTypeText(user_info.user_type)}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<Row gutter={16} style={{ marginTop: 24 }}>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Card className={styles.statCard}>
|
||||
<div className={styles.statCardContent}>
|
||||
<Text type="secondary">总答题数</Text>
|
||||
<Text strong style={{ fontSize: 24 }}>
|
||||
{user_info.total_answers}
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Card className={styles.statCard}>
|
||||
<div className={styles.statCardContent}>
|
||||
<Text type="secondary">答对数</Text>
|
||||
<Text strong style={{ fontSize: 24, color: '#52c41a' }}>
|
||||
<CheckCircleOutlined /> {user_info.correct_count}
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Card className={styles.statCard}>
|
||||
<div className={styles.statCardContent}>
|
||||
<Text type="secondary">答错数</Text>
|
||||
<Text strong style={{ fontSize: 24, color: '#ff4d4f' }}>
|
||||
<CloseCircleOutlined /> {user_info.wrong_count}
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Card className={styles.statCard}>
|
||||
<div className={styles.statCardContent}>
|
||||
<Text type="secondary">正确率</Text>
|
||||
<Text
|
||||
strong
|
||||
style={{
|
||||
fontSize: 24,
|
||||
color:
|
||||
user_info.accuracy >= 80
|
||||
? '#52c41a'
|
||||
: user_info.accuracy >= 60
|
||||
? '#1890ff'
|
||||
: '#faad14',
|
||||
}}
|
||||
>
|
||||
{user_info.accuracy.toFixed(1)}%
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 正确率进度条 */}
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Text type="secondary" style={{ marginBottom: 8, display: 'block' }}>
|
||||
答题准确率
|
||||
</Text>
|
||||
<Progress
|
||||
percent={user_info.accuracy}
|
||||
strokeColor={
|
||||
user_info.accuracy >= 80
|
||||
? '#52c41a'
|
||||
: user_info.accuracy >= 60
|
||||
? '#1890ff'
|
||||
: '#faad14'
|
||||
}
|
||||
strokeWidth={12}
|
||||
format={(percent) => `${percent?.toFixed(1)}%`}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 详细信息 */}
|
||||
<Card title="详细信息" className={styles.detailCard}>
|
||||
<Descriptions bordered column={{ xs: 1, sm: 2 }}>
|
||||
<Descriptions.Item label="用户名">{user_info.username}</Descriptions.Item>
|
||||
<Descriptions.Item label="姓名">{user_info.nickname || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="用户类型">
|
||||
<Tag color={user_info.user_type === 'ordinary-person' ? 'blue' : 'green'}>
|
||||
{getUserTypeText(user_info.user_type)}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="总答题数">{user_info.total_answers}</Descriptions.Item>
|
||||
<Descriptions.Item label="答对数">
|
||||
<Tag color="success">{user_info.correct_count}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="答错数">
|
||||
<Tag color="error">{user_info.wrong_count}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="注册时间">
|
||||
{formatDate(user_info.created_at)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="最后答题">
|
||||
{formatDate(user_info.last_answer_at)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
{/* 题型统计 */}
|
||||
{type_stats.length > 0 && (
|
||||
<Card title="题型统计" className={styles.typeStatsCard}>
|
||||
<Table
|
||||
dataSource={type_stats}
|
||||
rowKey="question_type"
|
||||
pagination={false}
|
||||
columns={[
|
||||
{
|
||||
title: '题型',
|
||||
dataIndex: 'question_type_name',
|
||||
key: 'question_type_name',
|
||||
render: (text: string) => <Text strong>{text}</Text>,
|
||||
},
|
||||
{
|
||||
title: '答题数',
|
||||
dataIndex: 'total_answers',
|
||||
key: 'total_answers',
|
||||
align: 'center',
|
||||
sorter: (a, b) => a.total_answers - b.total_answers,
|
||||
},
|
||||
{
|
||||
title: '答对数',
|
||||
dataIndex: 'correct_count',
|
||||
key: 'correct_count',
|
||||
align: 'center',
|
||||
render: (val: number) => <Tag color="success">{val}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '答错数',
|
||||
key: 'wrong_count',
|
||||
align: 'center',
|
||||
render: (_, record) => (
|
||||
<Tag color="error">{record.total_answers - record.correct_count}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '正确率',
|
||||
dataIndex: 'accuracy',
|
||||
key: 'accuracy',
|
||||
align: 'center',
|
||||
sorter: (a, b) => a.accuracy - b.accuracy,
|
||||
render: (val: number) => (
|
||||
<Tag color={val >= 80 ? 'success' : val >= 60 ? 'processing' : 'warning'}>
|
||||
{val.toFixed(1)}%
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserDetail
|
||||
@ -1,177 +0,0 @@
|
||||
.container {
|
||||
padding: 20px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.headerCard {
|
||||
margin-bottom: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
|
||||
:global {
|
||||
.ant-card-body {
|
||||
padding: 16px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.backButton {
|
||||
color: #007aff;
|
||||
font-weight: 500;
|
||||
padding: 4px 12px;
|
||||
transition: all 0.3s ease;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
|
||||
&:hover {
|
||||
color: #0051d5;
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
color: #1d1d1f !important;
|
||||
margin: 0 !important;
|
||||
font-weight: 700;
|
||||
font-size: 18px !important;
|
||||
text-align: center;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
}
|
||||
|
||||
.statCard {
|
||||
:global {
|
||||
.ant-card-body {
|
||||
padding: 16px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.userCard {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
.userCardHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
margin-bottom: 12px;
|
||||
border: 3px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.userInfo {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.statsSection {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 16px 0;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.statItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.progressSection {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.timeInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 10px 6px;
|
||||
}
|
||||
|
||||
.headerCard {
|
||||
margin-bottom: 10px;
|
||||
|
||||
:global {
|
||||
.ant-space-item {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.userCard {
|
||||
margin-bottom: 8px;
|
||||
|
||||
.userCardHeader {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.statsSection {
|
||||
flex-direction: row;
|
||||
gap: 4px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.statItem {
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
@ -1,484 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Typography,
|
||||
Space,
|
||||
message,
|
||||
Tag,
|
||||
Statistic,
|
||||
Row,
|
||||
Col,
|
||||
Avatar,
|
||||
Progress,
|
||||
Drawer,
|
||||
Descriptions,
|
||||
Table,
|
||||
Spin,
|
||||
} from 'antd'
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
UserOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import * as questionApi from '../api/question'
|
||||
import type { UserStats, UserDetailStats } from '../api/question'
|
||||
import styles from './UserManagement.module.less'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
const UserManagement: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [users, setUsers] = useState<UserStats[]>([])
|
||||
const [drawerVisible, setDrawerVisible] = useState(false)
|
||||
const [selectedUser, setSelectedUser] = useState<UserDetailStats | null>(null)
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
|
||||
// 加载用户列表
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await questionApi.getAllUsersWithStats()
|
||||
if (res.success && res.data) {
|
||||
setUsers(res.data)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('加载用户列表失败:', error)
|
||||
if (error.response?.status === 403) {
|
||||
message.error('无权访问,该功能仅限管理员使用')
|
||||
navigate('/')
|
||||
} else if (error.response?.status === 401) {
|
||||
message.error('请先登录')
|
||||
navigate('/login')
|
||||
} else {
|
||||
message.error('加载用户列表失败')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers()
|
||||
}, [])
|
||||
|
||||
// 查看用户详情
|
||||
const handleViewDetail = async (userId: number) => {
|
||||
try {
|
||||
setDetailLoading(true)
|
||||
setDrawerVisible(true)
|
||||
const res = await questionApi.getUserDetailStats(userId)
|
||||
if (res.success && res.data) {
|
||||
setSelectedUser(res.data)
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('加载用户详情失败')
|
||||
setDrawerVisible(false)
|
||||
} finally {
|
||||
setDetailLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateStr?: string) => {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
// 获取用户类型显示文本
|
||||
const getUserTypeText = (type?: string) => {
|
||||
if (!type) return '未设置'
|
||||
return type === 'ordinary-person' ? '普通涉密人员' : '保密管理人员'
|
||||
}
|
||||
|
||||
// 计算汇总统计
|
||||
const totalStats = {
|
||||
totalUsers: users.length,
|
||||
totalAnswers: users.reduce((sum, u) => sum + u.total_answers, 0),
|
||||
avgAccuracy:
|
||||
users.length > 0
|
||||
? users.reduce((sum, u) => sum + u.accuracy, 0) / users.length
|
||||
: 0,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* 页面标题和统计 */}
|
||||
<Card className={styles.headerCard}>
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<div className={styles.header}>
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/')}
|
||||
type="text"
|
||||
className={styles.backButton}
|
||||
>
|
||||
返回首页
|
||||
</Button>
|
||||
<Title level={3} className={styles.title}>
|
||||
用户管理
|
||||
</Title>
|
||||
</div>
|
||||
|
||||
<Row gutter={8}>
|
||||
<Col xs={8} sm={8}>
|
||||
<Card
|
||||
className={styles.statCard}
|
||||
style={{ padding: 0 }}
|
||||
styles={{
|
||||
body: {
|
||||
padding: '16px',
|
||||
}
|
||||
}}
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
>
|
||||
<Statistic
|
||||
title="总用户数"
|
||||
value={totalStats.totalUsers}
|
||||
prefix={<UserOutlined />}
|
||||
valueStyle={{ color: '#1890ff' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={8} sm={8}>
|
||||
<Card
|
||||
className={styles.statCard}
|
||||
style={{ padding: 0 }}
|
||||
styles={{
|
||||
body: {
|
||||
padding: '16px',
|
||||
}
|
||||
}}
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
>
|
||||
<Statistic
|
||||
title="总答题数"
|
||||
value={totalStats.totalAnswers}
|
||||
valueStyle={{ color: '#52c41a' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={8} sm={8}>
|
||||
<Card
|
||||
className={styles.statCard}
|
||||
style={{ padding: 0 }}
|
||||
styles={{
|
||||
body: {
|
||||
padding: '16px',
|
||||
}
|
||||
}}
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
>
|
||||
<Statistic
|
||||
title="平均正确率"
|
||||
value={totalStats.avgAccuracy.toFixed(1)}
|
||||
suffix="%"
|
||||
valueStyle={{ color: '#faad14' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* 用户卡片列表 */}
|
||||
<Row gutter={[8, 8]}>
|
||||
{users.map((user) => (
|
||||
<Col xs={24} sm={12} md={12} lg={8} xl={6} key={user.user_id}>
|
||||
<Card
|
||||
className={styles.userCard}
|
||||
styles={{
|
||||
body: {
|
||||
padding: '16px',
|
||||
}
|
||||
}}
|
||||
hoverable
|
||||
loading={loading}
|
||||
onClick={() => handleViewDetail(user.user_id)}
|
||||
>
|
||||
{/* 用户基本信息 */}
|
||||
<div className={styles.userCardHeader}>
|
||||
<Avatar
|
||||
size={64}
|
||||
src={user.avatar || undefined}
|
||||
icon={<UserOutlined />}
|
||||
className={styles.avatar}
|
||||
/>
|
||||
<div className={styles.userInfo}>
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
{user.nickname || user.username}
|
||||
</Title>
|
||||
<Text type="secondary">@{user.username}</Text>
|
||||
<Tag
|
||||
color={user.user_type === 'ordinary-person' ? 'blue' : 'green'}
|
||||
style={{ marginTop: 4 }}
|
||||
>
|
||||
{getUserTypeText(user.user_type)}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计数据 */}
|
||||
<div className={styles.statsSection}>
|
||||
<div className={styles.statItem}>
|
||||
<Text type="secondary" className={styles.statLabel}>
|
||||
总答题数
|
||||
</Text>
|
||||
<Text strong className={styles.statValue}>
|
||||
{user.total_answers}
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<Text type="secondary" className={styles.statLabel}>
|
||||
答对数
|
||||
</Text>
|
||||
<Text strong className={styles.statValue} style={{ color: '#52c41a' }}>
|
||||
<CheckCircleOutlined /> {user.correct_count}
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<Text type="secondary" className={styles.statLabel}>
|
||||
答错数
|
||||
</Text>
|
||||
<Text strong className={styles.statValue} style={{ color: '#ff4d4f' }}>
|
||||
<CloseCircleOutlined /> {user.wrong_count}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 正确率进度条 */}
|
||||
<div className={styles.progressSection}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
正确率
|
||||
</Text>
|
||||
<Progress
|
||||
percent={user.accuracy}
|
||||
strokeColor={
|
||||
user.accuracy >= 80
|
||||
? '#52c41a'
|
||||
: user.accuracy >= 60
|
||||
? '#1890ff'
|
||||
: '#faad14'
|
||||
}
|
||||
format={(percent) => `${percent?.toFixed(1)}%`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 时间信息 */}
|
||||
<div className={styles.timeInfo}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
注册:{formatDate(user.created_at)}
|
||||
</Text>
|
||||
{user.last_answer_at && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
最后答题:{formatDate(user.last_answer_at)}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
{/* 用户详情抽屉 */}
|
||||
<Drawer
|
||||
title="用户详细统计"
|
||||
placement="right"
|
||||
width={800}
|
||||
open={drawerVisible}
|
||||
onClose={() => setDrawerVisible(false)}
|
||||
styles={{
|
||||
body: { paddingTop: 12 }
|
||||
}}
|
||||
>
|
||||
{detailLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: '50px 0' }}>
|
||||
<Spin size="large">
|
||||
<div style={{ paddingTop: 50 }}>加载中...</div>
|
||||
</Spin>
|
||||
</div>
|
||||
) : (
|
||||
selectedUser && (
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
{/* 用户基本信息 */}
|
||||
<div style={{ textAlign: 'center', paddingBottom: 24, borderBottom: '1px solid #f0f0f0' }}>
|
||||
<Avatar
|
||||
size={80}
|
||||
src={selectedUser.user_info.avatar || undefined}
|
||||
icon={<UserOutlined />}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<Title level={4} style={{ margin: '0 0 8px 0' }}>
|
||||
{selectedUser.user_info.nickname || selectedUser.user_info.username}
|
||||
</Title>
|
||||
<Text type="secondary">@{selectedUser.user_info.username}</Text>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Tag color={selectedUser.user_info.user_type === 'ordinary-person' ? 'blue' : 'green'}>
|
||||
{getUserTypeText(selectedUser.user_info.user_type)}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计数据 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-around',
|
||||
padding: '16px 0',
|
||||
borderBottom: '1px solid #f0f0f0'
|
||||
}}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
|
||||
总答题数
|
||||
</Text>
|
||||
<Text strong style={{ fontSize: 20, color: '#1890ff' }}>
|
||||
{selectedUser.user_info.total_answers}
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
|
||||
答对数
|
||||
</Text>
|
||||
<Text strong style={{ fontSize: 20, color: '#52c41a' }}>
|
||||
<CheckCircleOutlined /> {selectedUser.user_info.correct_count}
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
|
||||
答错数
|
||||
</Text>
|
||||
<Text strong style={{ fontSize: 20, color: '#ff4d4f' }}>
|
||||
<CloseCircleOutlined /> {selectedUser.user_info.wrong_count}
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
|
||||
正确率
|
||||
</Text>
|
||||
<Text
|
||||
strong
|
||||
style={{
|
||||
fontSize: 20,
|
||||
color: selectedUser.user_info.accuracy >= 80
|
||||
? '#52c41a'
|
||||
: selectedUser.user_info.accuracy >= 60
|
||||
? '#1890ff'
|
||||
: '#faad14'
|
||||
}}
|
||||
>
|
||||
{selectedUser.user_info.accuracy.toFixed(1)}%
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 正确率进度条 */}
|
||||
<div>
|
||||
<Text type="secondary" style={{ marginBottom: 8, display: 'block' }}>
|
||||
答题准确率
|
||||
</Text>
|
||||
<Progress
|
||||
percent={selectedUser.user_info.accuracy}
|
||||
strokeColor={
|
||||
selectedUser.user_info.accuracy >= 80
|
||||
? '#52c41a'
|
||||
: selectedUser.user_info.accuracy >= 60
|
||||
? '#1890ff'
|
||||
: '#faad14'
|
||||
}
|
||||
strokeWidth={12}
|
||||
format={(percent) => `${percent?.toFixed(1)}%`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 详细信息 */}
|
||||
<Descriptions bordered column={1} size="small">
|
||||
<Descriptions.Item label="用户名">
|
||||
{selectedUser.user_info.username}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="姓名">
|
||||
{selectedUser.user_info.nickname || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="用户类型">
|
||||
<Tag color={selectedUser.user_info.user_type === 'ordinary-person' ? 'blue' : 'green'}>
|
||||
{getUserTypeText(selectedUser.user_info.user_type)}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="注册时间">
|
||||
{formatDate(selectedUser.user_info.created_at)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="最后答题">
|
||||
{formatDate(selectedUser.user_info.last_answer_at)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{/* 题型统计 */}
|
||||
{selectedUser.type_stats && selectedUser.type_stats.length > 0 && (
|
||||
<>
|
||||
<Title level={5}>题型统计</Title>
|
||||
<Table
|
||||
dataSource={selectedUser.type_stats}
|
||||
rowKey="question_type"
|
||||
pagination={false}
|
||||
size="small"
|
||||
columns={[
|
||||
{
|
||||
title: '题型',
|
||||
dataIndex: 'question_type_name',
|
||||
key: 'question_type_name',
|
||||
render: (text: string) => <Text strong>{text}</Text>,
|
||||
},
|
||||
{
|
||||
title: '答题数',
|
||||
dataIndex: 'total_answers',
|
||||
key: 'total_answers',
|
||||
align: 'center',
|
||||
sorter: (a, b) => a.total_answers - b.total_answers,
|
||||
},
|
||||
{
|
||||
title: '答对数',
|
||||
dataIndex: 'correct_count',
|
||||
key: 'correct_count',
|
||||
align: 'center',
|
||||
render: (val: number) => <Tag color="success">{val}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '答错数',
|
||||
key: 'wrong_count',
|
||||
align: 'center',
|
||||
render: (_, record) => (
|
||||
<Tag color="error">{record.total_answers - record.correct_count}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '正确率',
|
||||
dataIndex: 'accuracy',
|
||||
key: 'accuracy',
|
||||
align: 'center',
|
||||
sorter: (a, b) => a.accuracy - b.accuracy,
|
||||
render: (val: number) => (
|
||||
<Tag color={val >= 80 ? 'success' : val >= 60 ? 'processing' : 'warning'}>
|
||||
{val.toFixed(1)}%
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserManagement
|
||||
@ -1,531 +0,0 @@
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
background: #fafafa;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 20px;
|
||||
padding-bottom: 16px;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
|
||||
.backButton {
|
||||
color: #007aff;
|
||||
font-weight: 500;
|
||||
padding: 4px 12px;
|
||||
transition: all 0.3s ease;
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
|
||||
&:hover {
|
||||
color: #0051d5;
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
color: #1d1d1f !important;
|
||||
margin: 0 !important;
|
||||
font-weight: 700;
|
||||
font-size: 18px !important;
|
||||
text-align: center;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
}
|
||||
|
||||
// 统计卡片容器
|
||||
.statsContainer {
|
||||
padding: 0 20px 16px;
|
||||
}
|
||||
|
||||
.statCard {
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(30px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(30px) saturate(180%);
|
||||
box-shadow:
|
||||
0 2px 12px rgba(0, 0, 0, 0.05),
|
||||
0 1px 4px rgba(0, 0, 0, 0.03),
|
||||
0 0 0 1px rgba(0, 0, 0, 0.03);
|
||||
border: 0.5px solid rgba(0, 0, 0, 0.04);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.08),
|
||||
0 4px 16px rgba(0, 0, 0, 0.04),
|
||||
0 0 0 1px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
:global {
|
||||
.ant-statistic-title {
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.ant-statistic-content {
|
||||
font-size: 28px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 筛选卡片
|
||||
.filterCard {
|
||||
margin: 0 20px 16px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(30px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(30px) saturate(180%);
|
||||
box-shadow:
|
||||
0 2px 12px rgba(0, 0, 0, 0.05),
|
||||
0 1px 4px rgba(0, 0, 0, 0.03),
|
||||
0 0 0 1px rgba(0, 0, 0, 0.03);
|
||||
border: 0.5px solid rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.filterContent {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filterLeft {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.filterRight {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// 操作按钮卡片(已废弃,保留样式以防需要)
|
||||
.actionCard {
|
||||
margin: 0 20px 16px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(30px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(30px) saturate(180%);
|
||||
box-shadow:
|
||||
0 2px 12px rgba(0, 0, 0, 0.05),
|
||||
0 1px 4px rgba(0, 0, 0, 0.03),
|
||||
0 0 0 1px rgba(0, 0, 0, 0.03);
|
||||
border: 0.5px solid rgba(0, 0, 0, 0.04);
|
||||
|
||||
:global {
|
||||
.ant-card-body {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.primaryButton {
|
||||
background: #1890ff;
|
||||
border: none;
|
||||
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
min-width: 120px;
|
||||
|
||||
&:hover {
|
||||
background: #40a9ff;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.4);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #d9d9d9;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.clearButton {
|
||||
background: #fff;
|
||||
border: 1px solid #d9d9d9;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
transition: all 0.3s ease;
|
||||
min-width: 110px;
|
||||
|
||||
&:hover {
|
||||
color: #ff4d4f;
|
||||
border-color: #ff4d4f;
|
||||
background: #fff1f0;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 2px 8px rgba(255, 77, 79, 0.2);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: rgba(0, 0, 0, 0.25);
|
||||
background: #f5f5f5;
|
||||
border-color: #d9d9d9;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.listCard {
|
||||
margin: 0 20px 20px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(30px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(30px) saturate(180%);
|
||||
box-shadow:
|
||||
0 2px 12px rgba(0, 0, 0, 0.05),
|
||||
0 1px 4px rgba(0, 0, 0, 0.03),
|
||||
0 0 0 1px rgba(0, 0, 0, 0.03);
|
||||
border: 0.5px solid rgba(0, 0, 0, 0.04);
|
||||
padding-bottom: 60px;
|
||||
|
||||
:global {
|
||||
.ant-list-item {
|
||||
border: none !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.listItem {
|
||||
padding: 0 !important;
|
||||
margin-bottom: 16px !important;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 题目卡片
|
||||
.questionCard {
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.03),
|
||||
0 1px 6px -1px rgba(0, 0, 0, 0.02),
|
||||
0 2px 4px rgba(0, 0, 0, 0.02);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.questionHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.questionId {
|
||||
font-size: 16px;
|
||||
color: #1d1d1f;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.deleteButton {
|
||||
color: #ff4d4f;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
color: #ff7875;
|
||||
background: rgba(255, 77, 79, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
// 题目内容
|
||||
.questionContent {
|
||||
color: #1d1d1f;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
// 答案区域
|
||||
.answerSection {
|
||||
margin-bottom: 12px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.answerRow {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.answerLabel {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.answerValue {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
// 掌握度进度条区域
|
||||
.masteryProgress {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.progressHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.progressLabel {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1d1d1f;
|
||||
}
|
||||
|
||||
.masteryTag {
|
||||
font-weight: 600;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.questionFooter {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 16px;
|
||||
margin-top: 16px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
// 响应式设计 - 移动端
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 16px;
|
||||
|
||||
.backButton {
|
||||
font-size: 14px;
|
||||
padding: 4px 8px;
|
||||
left: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.statsContainer {
|
||||
padding: 0 16px 12px;
|
||||
}
|
||||
|
||||
.statCard {
|
||||
border-radius: 12px;
|
||||
|
||||
:global {
|
||||
.ant-statistic-title {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ant-statistic-content {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filterCard {
|
||||
margin: 0 16px 12px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.filterContent {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filterLeft {
|
||||
min-width: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filterRight {
|
||||
width: 100%;
|
||||
|
||||
:global {
|
||||
.ant-space {
|
||||
width: 100%;
|
||||
justify-content: stretch;
|
||||
|
||||
.ant-space-item {
|
||||
flex: 1;
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.primaryButton,
|
||||
.clearButton {
|
||||
min-width: auto;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.actionCard {
|
||||
margin: 0 16px 12px;
|
||||
border-radius: 12px;
|
||||
|
||||
:global {
|
||||
.ant-card-body {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.listCard {
|
||||
margin: 0 16px 16px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.questionCard {
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.questionHeader {
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.questionContent {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.masteryProgress {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.progressHeader {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.progressLabel {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.masteryTag {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.questionFooter {
|
||||
padding-top: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.questionId {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计 - PC端
|
||||
@media (min-width: 769px) {
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 32px 32px 24px;
|
||||
|
||||
.title {
|
||||
font-size: 20px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.statsContainer {
|
||||
padding: 0 32px 20px;
|
||||
}
|
||||
|
||||
.filterCard {
|
||||
margin: 0 32px 20px;
|
||||
}
|
||||
|
||||
.actionCard {
|
||||
margin: 0 32px 20px;
|
||||
}
|
||||
|
||||
.listCard {
|
||||
margin: 0 32px 32px;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.questionCard {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.questionHeader {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.questionContent {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.answerSection {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.masteryProgress {
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.progressHeader {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.progressLabel {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.masteryTag {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.questionFooter {
|
||||
padding-top: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计 - 超宽屏
|
||||
@media (min-width: 1600px) {
|
||||
.container {
|
||||
max-width: 1600px;
|
||||
}
|
||||
|
||||
.header {
|
||||
.title {
|
||||
font-size: 22px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,415 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Card, List, Button, Tag, Typography, Space, message, Modal, Empty, Statistic, Progress, Select, Row, Col } from 'antd'
|
||||
import {
|
||||
CloseCircleOutlined,
|
||||
ArrowLeftOutlined,
|
||||
PlayCircleOutlined,
|
||||
DeleteOutlined,
|
||||
TrophyOutlined,
|
||||
FireOutlined,
|
||||
CheckCircleOutlined,
|
||||
FilterOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import * as questionApi from '../api/question'
|
||||
import type { WrongQuestion, WrongQuestionStats, WrongQuestionFilter } from '../types/question'
|
||||
import styles from './WrongQuestions.module.less'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
const { Option } = Select
|
||||
|
||||
const WrongQuestions: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [wrongQuestions, setWrongQuestions] = useState<WrongQuestion[]>([])
|
||||
const [stats, setStats] = useState<WrongQuestionStats | null>(null)
|
||||
const [filter, setFilter] = useState<WrongQuestionFilter>({})
|
||||
|
||||
// 加载错题列表
|
||||
const loadWrongQuestions = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await questionApi.getWrongQuestions(filter)
|
||||
if (res.success && res.data) {
|
||||
setWrongQuestions(res.data)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('加载错题列表失败:', error)
|
||||
if (error.response?.status === 401) {
|
||||
message.error('请先登录')
|
||||
navigate('/login')
|
||||
} else {
|
||||
message.error('加载错题列表失败')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载统计数据
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const res = await questionApi.getWrongQuestionStats()
|
||||
if (res.success && res.data) {
|
||||
setStats(res.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载统计数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadWrongQuestions()
|
||||
loadStats()
|
||||
}, [filter])
|
||||
|
||||
// 清空错题本
|
||||
const handleClear = () => {
|
||||
Modal.confirm({
|
||||
title: '确认清空错题本?',
|
||||
content: '清空后将无法恢复,请确认操作',
|
||||
okText: '确认清空',
|
||||
cancelText: '取消',
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
try {
|
||||
const res = await questionApi.clearWrongQuestions()
|
||||
if (res.success) {
|
||||
message.success('已清空错题本')
|
||||
loadWrongQuestions()
|
||||
loadStats()
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('清空失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 删除单个错题
|
||||
const handleDelete = (id: number) => {
|
||||
Modal.confirm({
|
||||
title: '确定要删除这道错题吗?',
|
||||
content: '删除后将无法恢复',
|
||||
okText: '确定',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
const res = await questionApi.deleteWrongQuestion(id)
|
||||
if (res.success) {
|
||||
message.success('已删除')
|
||||
loadWrongQuestions()
|
||||
loadStats()
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('删除失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 开始错题练习
|
||||
const handlePractice = () => {
|
||||
// 跳转到答题页面,错题练习模式
|
||||
navigate('/question?mode=wrong')
|
||||
}
|
||||
|
||||
// 格式化答案显示
|
||||
const formatAnswer = (answer: string | string[], questionType: string) => {
|
||||
if (questionType === 'true-false') {
|
||||
const strAnswer = String(answer)
|
||||
return strAnswer === 'true' ? '正确' : '错误'
|
||||
}
|
||||
if (Array.isArray(answer)) {
|
||||
return answer.join(', ')
|
||||
}
|
||||
return String(answer)
|
||||
}
|
||||
|
||||
// 渲染填空题内容(将 **** 替换为下划线)
|
||||
const renderFillInBlankContent = (content: string) => {
|
||||
const parts = content.split('****')
|
||||
if (parts.length === 1) {
|
||||
return content
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
{parts.map((part, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{part}
|
||||
{index < parts.length - 1 && (
|
||||
<span> ________ </span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// 获取题型标签颜色
|
||||
const getTypeColor = (type: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
'multiple-choice': 'blue',
|
||||
'multiple-selection': 'green',
|
||||
'fill-in-blank': 'cyan',
|
||||
'true-false': 'orange',
|
||||
'short-answer': 'purple',
|
||||
}
|
||||
return colorMap[type] || 'default'
|
||||
}
|
||||
|
||||
// 获取掌握度进度条颜色
|
||||
const getMasteryColor = (level: number): string => {
|
||||
if (level === 0) return '#ff4d4f'
|
||||
if (level < 30) return '#ff7a45'
|
||||
if (level < 60) return '#ffa940'
|
||||
if (level < 100) return '#52c41a'
|
||||
return '#1890ff'
|
||||
}
|
||||
|
||||
// 获取掌握度标签
|
||||
const getMasteryLabel = (level: number): { text: string; color: string } => {
|
||||
if (level === 0) return { text: '未掌握', color: 'error' }
|
||||
if (level < 30) return { text: '较差', color: 'error' }
|
||||
if (level < 60) return { text: '一般', color: 'warning' }
|
||||
if (level < 100) return { text: '良好', color: 'success' }
|
||||
return { text: '已掌握', color: 'success' }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* 头部 */}
|
||||
<div className={styles.header}>
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/')}
|
||||
type="text"
|
||||
className={styles.backButton}
|
||||
>
|
||||
返回
|
||||
</Button>
|
||||
<Title level={3} className={styles.title}>
|
||||
错题本
|
||||
</Title>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<Row gutter={[16, 16]} className={styles.statsContainer}>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card className={styles.statCard}>
|
||||
<Statistic
|
||||
title="错题总数"
|
||||
value={stats?.total_wrong || 0}
|
||||
valueStyle={{ color: '#ff4d4f', fontSize: '28px', fontWeight: 'bold' }}
|
||||
prefix={<CloseCircleOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card className={styles.statCard}>
|
||||
<Statistic
|
||||
title="已掌握"
|
||||
value={stats?.mastered || 0}
|
||||
valueStyle={{ color: '#52c41a', fontSize: '28px', fontWeight: 'bold' }}
|
||||
prefix={<CheckCircleOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card className={styles.statCard}>
|
||||
<Statistic
|
||||
title="未掌握"
|
||||
value={stats?.not_mastered || 0}
|
||||
valueStyle={{ color: '#faad14', fontSize: '28px', fontWeight: 'bold' }}
|
||||
prefix={<FireOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card className={styles.statCard}>
|
||||
<Statistic
|
||||
title="掌握率"
|
||||
value={stats?.total_wrong ? Math.round((stats.mastered / stats.total_wrong) * 100) : 0}
|
||||
valueStyle={{ color: '#1890ff', fontSize: '28px', fontWeight: 'bold' }}
|
||||
prefix={<TrophyOutlined />}
|
||||
suffix="%"
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 筛选和操作区域 */}
|
||||
<Card className={styles.filterCard}>
|
||||
<div className={styles.filterContent}>
|
||||
<Space wrap className={styles.filterLeft}>
|
||||
<Space>
|
||||
<FilterOutlined />
|
||||
<Text strong>筛选与排序:</Text>
|
||||
</Space>
|
||||
<Select
|
||||
placeholder="掌握状态"
|
||||
style={{ width: 120 }}
|
||||
allowClear
|
||||
onChange={(value) => setFilter({ ...filter, is_mastered: value })}
|
||||
>
|
||||
<Option value={false}>未掌握</Option>
|
||||
<Option value={true}>已掌握</Option>
|
||||
</Select>
|
||||
<Select
|
||||
placeholder="排序方式"
|
||||
style={{ width: 140 }}
|
||||
defaultValue="time"
|
||||
onChange={(value: 'time' | 'wrong_count' | 'mastery_level') => setFilter({ ...filter, sort: value })}
|
||||
>
|
||||
<Option value="time">按时间排序</Option>
|
||||
<Option value="wrong_count">按错误次数</Option>
|
||||
<Option value="mastery_level">按掌握度</Option>
|
||||
</Select>
|
||||
</Space>
|
||||
<Space size="middle" className={styles.filterRight}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={handlePractice}
|
||||
disabled={!wrongQuestions.length || (stats?.total_wrong === stats?.mastered && (stats?.total_wrong ?? 0) > 0)}
|
||||
className={styles.primaryButton}
|
||||
>
|
||||
开始练习
|
||||
</Button>
|
||||
<Button
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={handleClear}
|
||||
disabled={!wrongQuestions.length}
|
||||
className={styles.clearButton}
|
||||
>
|
||||
清空错题本
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 错题列表 */}
|
||||
<Card className={styles.listCard}>
|
||||
{wrongQuestions.length === 0 ? (
|
||||
<Empty
|
||||
description="暂无错题,继续加油!"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
) : (
|
||||
<List
|
||||
loading={loading}
|
||||
dataSource={wrongQuestions}
|
||||
renderItem={(item) => {
|
||||
const masteryLabel = getMasteryLabel(item.mastery_level)
|
||||
return (
|
||||
<List.Item
|
||||
key={item.id}
|
||||
className={styles.listItem}
|
||||
>
|
||||
<div className={styles.questionCard}>
|
||||
{/* 题目头部 */}
|
||||
<div className={styles.questionHeader}>
|
||||
<Space wrap>
|
||||
<Text strong className={styles.questionId}>
|
||||
第 {item.question?.question_id || item.question?.id || item.question_id} 题
|
||||
</Text>
|
||||
{item.question && (
|
||||
<Tag color={getTypeColor(item.question.type)}>
|
||||
{item.question.category || item.question.type}
|
||||
</Tag>
|
||||
)}
|
||||
<Tag color="error" icon={<CloseCircleOutlined />}>
|
||||
错 {item.total_wrong_count} 次
|
||||
</Tag>
|
||||
{item.is_mastered && (
|
||||
<Tag color="success" icon={<CheckCircleOutlined />}>
|
||||
已掌握
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleDelete(item.id)}
|
||||
className={styles.deleteButton}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 题目内容 */}
|
||||
{item.question && (
|
||||
<div className={styles.questionContent}>
|
||||
<Text>
|
||||
{item.question.type === 'fill-in-blank'
|
||||
? renderFillInBlankContent(item.question.content)
|
||||
: item.question.content}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 答案信息 */}
|
||||
{item.recent_history && item.recent_history.length > 0 && (
|
||||
<div className={styles.answerSection}>
|
||||
<div className={styles.answerRow}>
|
||||
<Text type="danger" className={styles.answerLabel}>
|
||||
最近答案:
|
||||
</Text>
|
||||
<Text className={styles.answerValue}>
|
||||
{formatAnswer(item.recent_history[0].user_answer, item.question?.type || '')}
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.answerRow}>
|
||||
<Text type="success" className={styles.answerLabel}>
|
||||
正确答案:
|
||||
</Text>
|
||||
<Text className={styles.answerValue}>
|
||||
{formatAnswer(item.recent_history[0].correct_answer, item.question?.type || '')}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 掌握度进度条 */}
|
||||
<div className={styles.masteryProgress}>
|
||||
<div className={styles.progressHeader}>
|
||||
<Text className={styles.progressLabel}>掌握度</Text>
|
||||
<Tag color={masteryLabel.color} className={styles.masteryTag}>
|
||||
{masteryLabel.text}
|
||||
</Tag>
|
||||
</div>
|
||||
<Progress
|
||||
percent={item.mastery_level}
|
||||
strokeColor={getMasteryColor(item.mastery_level)}
|
||||
strokeWidth={8}
|
||||
showInfo={true}
|
||||
format={(percent) => `${percent}%`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 底部信息 */}
|
||||
<div className={styles.questionFooter}>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
最后错误:{new Date(item.last_wrong_time).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</List.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WrongQuestions
|
||||
@ -1,248 +0,0 @@
|
||||
import { Question } from './question'
|
||||
|
||||
// ========== 新版数据结构 ==========
|
||||
|
||||
// 题型配置
|
||||
export interface QuestionTypeConfig {
|
||||
type: string
|
||||
count: number
|
||||
score: number
|
||||
}
|
||||
|
||||
// 试卷配置
|
||||
export interface ExamConfig {
|
||||
question_types: QuestionTypeConfig[]
|
||||
categories?: string[]
|
||||
random_order: boolean
|
||||
}
|
||||
|
||||
// 试卷模型
|
||||
export interface ExamModel {
|
||||
id: number
|
||||
user_id: number
|
||||
title: string
|
||||
total_score: number
|
||||
duration: number // 分钟
|
||||
pass_score: number
|
||||
question_ids: number[]
|
||||
status: 'active' | 'archived'
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// AI评分结果
|
||||
export interface AIGrading {
|
||||
score: number
|
||||
feedback: string
|
||||
suggestion: string
|
||||
}
|
||||
|
||||
// 考试答案
|
||||
export interface ExamAnswer {
|
||||
question_id: number
|
||||
answer: any
|
||||
correct_answer: any
|
||||
is_correct: boolean
|
||||
score: number
|
||||
ai_grading?: AIGrading
|
||||
}
|
||||
|
||||
// 考试记录
|
||||
export interface ExamRecord {
|
||||
id: number
|
||||
exam_id: number
|
||||
user_id: number
|
||||
start_time?: string
|
||||
submit_time?: string
|
||||
time_spent: number // 秒
|
||||
score: number
|
||||
total_score: number
|
||||
answers: ExamAnswer[]
|
||||
status: 'in_progress' | 'submitted' | 'graded'
|
||||
is_passed: boolean
|
||||
exam?: ExamModel
|
||||
user?: { // 用户信息(共享试卷时返回)
|
||||
id: number
|
||||
username: string
|
||||
nickname: string
|
||||
avatar: string
|
||||
}
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// 创建试卷请求
|
||||
export interface CreateExamRequest {
|
||||
title: string
|
||||
duration: number
|
||||
pass_score?: number
|
||||
question_types: QuestionTypeConfig[]
|
||||
categories?: string[]
|
||||
random_order?: boolean
|
||||
}
|
||||
|
||||
// 创建试卷响应
|
||||
export interface CreateExamResponse {
|
||||
id: number
|
||||
title: string
|
||||
total_score: number
|
||||
duration: number
|
||||
pass_score: number
|
||||
question_count: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// 试卷列表响应
|
||||
export type ExamListResponse = Array<{
|
||||
id: number
|
||||
title: string
|
||||
total_score: number
|
||||
duration: number
|
||||
pass_score: number
|
||||
question_count: number
|
||||
attempt_count: number
|
||||
best_score: number
|
||||
has_in_progress_exam: boolean
|
||||
in_progress_record_id?: number
|
||||
participant_count: number // 共享试卷的参与人数
|
||||
is_shared: boolean // 是否为被分享的试卷(原:是否为分享副本)
|
||||
shared_by_id?: number // 分享人ID(已废弃,使用 shared_by)
|
||||
shared_by?: { // 分享人信息(原:副本创建者)
|
||||
id: number
|
||||
username: string
|
||||
nickname: string
|
||||
}
|
||||
created_at: string
|
||||
}>
|
||||
|
||||
// 试卷详情响应
|
||||
export interface ExamDetailResponse {
|
||||
exam: ExamModel
|
||||
questions: Question[]
|
||||
}
|
||||
|
||||
// 开始考试响应
|
||||
export interface StartExamResponse {
|
||||
record_id: number
|
||||
start_time: string
|
||||
duration: number
|
||||
}
|
||||
|
||||
// 提交试卷响应
|
||||
export interface SubmitExamResponse {
|
||||
record_id?: number // 后端返回的考试记录ID
|
||||
score?: number
|
||||
total_score?: number
|
||||
is_passed?: boolean
|
||||
time_spent?: number
|
||||
status?: string
|
||||
answers?: ExamAnswer[]
|
||||
detailed_results?: Record<string, {
|
||||
correct: boolean
|
||||
score: number
|
||||
message?: string
|
||||
ai_grading?: {
|
||||
score: number
|
||||
feedback: string
|
||||
suggestion: string
|
||||
}
|
||||
}>
|
||||
}
|
||||
|
||||
// 考试记录响应
|
||||
export interface ExamRecordResponse {
|
||||
record: ExamRecord
|
||||
answers: ExamAnswer[]
|
||||
}
|
||||
|
||||
// 考试记录列表响应
|
||||
export type ExamRecordListResponse = ExamRecord[]
|
||||
|
||||
// ========== 旧版数据结构(兼容) ==========
|
||||
|
||||
// 考试记录
|
||||
export interface Exam {
|
||||
id: number
|
||||
user_id: number
|
||||
question_ids: string
|
||||
answers: string
|
||||
score: number
|
||||
status: 'draft' | 'submitted'
|
||||
submitted_at?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// 考试题目配置
|
||||
export interface ExamQuestionConfig {
|
||||
fill_in_blank: number // 填空题数量
|
||||
true_false: number // 判断题数量
|
||||
multiple_choice: number // 单选题数量
|
||||
multiple_selection: number // 多选题数量
|
||||
short_answer: number // 简答题数量
|
||||
ordinary_essay: number // 普通涉密人员论述题数量
|
||||
management_essay: number // 保密管理人员论述题数量
|
||||
}
|
||||
|
||||
// 考试分值配置
|
||||
export interface ExamScoreConfig {
|
||||
fill_in_blank: number // 填空题分值
|
||||
true_false: number // 判断题分值
|
||||
multiple_choice: number // 单选题分值
|
||||
multiple_selection: number // 多选题分值
|
||||
essay: number // 论述题分值
|
||||
}
|
||||
|
||||
// 生成考试响应
|
||||
export interface GenerateExamResponse {
|
||||
exam_id: number
|
||||
question_ids: number[]
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// 获取考试响应
|
||||
export interface GetExamResponse {
|
||||
exam: Exam
|
||||
questions: Question[]
|
||||
}
|
||||
|
||||
// 提交考试请求
|
||||
export interface SubmitExamRequest {
|
||||
answers?: Record<string, any> // question_id -> answer (可选,后端会从数据库读取)
|
||||
essay_choice?: 'ordinary' | 'management' // 论述题选择
|
||||
}
|
||||
|
||||
// 提交考试响应(旧版)
|
||||
export interface SubmitExamResponseOld {
|
||||
score: number
|
||||
detailed_results: Record<string, {
|
||||
correct: boolean
|
||||
score: number
|
||||
message?: string
|
||||
ai_grading?: {
|
||||
score: number
|
||||
feedback: string
|
||||
suggestion: string
|
||||
}
|
||||
}>
|
||||
}
|
||||
|
||||
// 默认配置
|
||||
export const DEFAULT_EXAM_CONFIG: ExamQuestionConfig = {
|
||||
fill_in_blank: 10,
|
||||
true_false: 10,
|
||||
multiple_choice: 10,
|
||||
multiple_selection: 10,
|
||||
short_answer: 2,
|
||||
ordinary_essay: 1,
|
||||
management_essay: 1,
|
||||
}
|
||||
|
||||
export const DEFAULT_SCORE_CONFIG: ExamScoreConfig = {
|
||||
fill_in_blank: 2.0,
|
||||
true_false: 2.0,
|
||||
multiple_choice: 1.0,
|
||||
multiple_selection: 2.5,
|
||||
essay: 25.0,
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
// 题目类型 - 使用数据库中的实际类型
|
||||
export type QuestionType = 'multiple-choice' | 'multiple-selection' | 'fill-in-blank' | 'true-false' | 'short-answer' | 'ordinary-essay' | 'management-essay'
|
||||
// 题目类型
|
||||
export type QuestionType = 'single' | 'multiple' | 'fill' | 'judge' | 'short'
|
||||
|
||||
// 选项
|
||||
export interface Option {
|
||||
@ -10,37 +10,23 @@ export interface Option {
|
||||
// 题目
|
||||
export interface Question {
|
||||
id: number
|
||||
question_id: string // 题目编号
|
||||
type: QuestionType
|
||||
content: string
|
||||
options: Option[]
|
||||
category: string
|
||||
answer?: any // 正确答案(用于题目管理编辑)
|
||||
answer_lengths?: number[] // 答案长度数组(用于打印时计算横线长度)
|
||||
}
|
||||
|
||||
// 提交答案
|
||||
export interface SubmitAnswer {
|
||||
question_id: number
|
||||
answer: string | string[] | boolean
|
||||
}
|
||||
|
||||
// AI评分结果
|
||||
export interface AIGrading {
|
||||
score: number // 得分 (0-100)
|
||||
feedback: string // 评语
|
||||
suggestion: string // 改进建议
|
||||
reference_answer?: string // 参考答案(论述题)
|
||||
scoring_rationale?: string // 评分依据
|
||||
answer: string | string[]
|
||||
}
|
||||
|
||||
// 答案结果
|
||||
export interface AnswerResult {
|
||||
correct: boolean
|
||||
user_answer: string | string[] | boolean
|
||||
correct_answer: string | string[]
|
||||
explanation?: string
|
||||
ai_grading?: AIGrading // AI评分结果(简答题和论述题)
|
||||
}
|
||||
|
||||
// 统计数据
|
||||
@ -48,8 +34,6 @@ export interface Statistics {
|
||||
total_questions: number
|
||||
answered_questions: number
|
||||
correct_answers: number
|
||||
wrong_questions: number
|
||||
total_answers: number // 刷题次数
|
||||
accuracy: number
|
||||
}
|
||||
|
||||
@ -60,53 +44,3 @@ export interface ApiResponse<T> {
|
||||
message?: string
|
||||
total?: number
|
||||
}
|
||||
|
||||
// 错题历史记录
|
||||
export interface WrongQuestionHistory {
|
||||
id: number
|
||||
user_answer: string | string[]
|
||||
correct_answer: string | string[]
|
||||
answered_at: string
|
||||
time_spent: number // 答题用时(秒)
|
||||
is_correct: boolean
|
||||
}
|
||||
|
||||
// 错题记录
|
||||
export interface WrongQuestion {
|
||||
id: number
|
||||
question_id: number
|
||||
question?: Question
|
||||
first_wrong_time: string // 首次错误时间
|
||||
last_wrong_time: string // 最后错误时间
|
||||
total_wrong_count: number // 总错误次数
|
||||
mastery_level: number // 掌握度 (0-100)
|
||||
consecutive_correct: number // 连续答对次数
|
||||
is_mastered: boolean // 是否已掌握
|
||||
recent_history?: WrongQuestionHistory[] // 最近的历史记录
|
||||
}
|
||||
|
||||
// 错题趋势数据点
|
||||
export interface TrendPoint {
|
||||
date: string
|
||||
count: number
|
||||
}
|
||||
|
||||
// 错题统计
|
||||
export interface WrongQuestionStats {
|
||||
total_wrong: number // 总错题数
|
||||
mastered: number // 已掌握数
|
||||
not_mastered: number // 未掌握数
|
||||
need_review: number // 需要复习数
|
||||
type_stats: Record<string, number> // 按题型统计
|
||||
category_stats: Record<string, number> // 按分类统计
|
||||
mastery_level_dist: Record<string, number> // 掌握度分布
|
||||
trend_data: TrendPoint[] // 错题趋势(最近7天)
|
||||
}
|
||||
|
||||
// 错题筛选参数
|
||||
export interface WrongQuestionFilter {
|
||||
is_mastered?: boolean
|
||||
tag?: string
|
||||
type?: QuestionType
|
||||
sort?: 'wrong_count' | 'mastery_level' | 'time'
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
|
||||
// 创建 axios 实例
|
||||
const instance: AxiosInstance = axios.create({
|
||||
baseURL: '/api', // 通过 Vite 代理转发到 Go 后端
|
||||
timeout: 300000, // 5分钟超时(300秒),适应AI评分长时间处理
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
@ -34,12 +34,8 @@ instance.interceptors.response.use(
|
||||
if (error.response) {
|
||||
switch (error.response.status) {
|
||||
case 401:
|
||||
// 未授权,清除本地存储并跳转到登录页
|
||||
console.error('Token已过期或未授权,请重新登录')
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
// 跳转到登录页
|
||||
window.location.href = '/login'
|
||||
// 未授权,跳转到登录页
|
||||
console.error('未授权,请登录')
|
||||
break
|
||||
case 403:
|
||||
console.error('没有权限访问')
|
||||
@ -76,53 +72,4 @@ export const request = {
|
||||
},
|
||||
}
|
||||
|
||||
// 统一的 fetch 请求工具(用于需要原生 fetch 的场景,如流式请求)
|
||||
interface FetchOptions extends RequestInit {
|
||||
// 扩展选项(如果需要)
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一封装的 fetch 请求方法
|
||||
* 自动添加 Authorization header 和其他通用配置
|
||||
*/
|
||||
export const fetchWithAuth = async (
|
||||
url: string,
|
||||
options: FetchOptions = {}
|
||||
): Promise<Response> => {
|
||||
// 获取 token
|
||||
const token = localStorage.getItem('token')
|
||||
|
||||
// 合并 headers
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string>),
|
||||
}
|
||||
|
||||
// 如果有 token,添加到请求头
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
// 构建完整的请求配置
|
||||
const fetchOptions: RequestInit = {
|
||||
...options,
|
||||
headers,
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
const response = await fetch(url, fetchOptions)
|
||||
|
||||
// 统一处理 401 未授权错误
|
||||
// 注意:如果已经在登录页面,不要跳转,让登录页面自己处理错误提示
|
||||
if (response.status === 401 && !window.location.pathname.startsWith('/login')) {
|
||||
console.error('Token已过期或未授权,请重新登录')
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
window.location.href = '/login'
|
||||
throw new Error('未授权,请重新登录')
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
export default instance
|
||||
|
||||
@ -5,7 +5,6 @@ import path from 'path'
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: './', // 使用相对路径,确保打包后资源路径正确
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
@ -15,14 +14,9 @@ export default defineConfig({
|
||||
preprocessorOptions: {
|
||||
less: {
|
||||
javascriptEnabled: true,
|
||||
// antd 主题定制 - 白色毛玻璃风格
|
||||
// 可以在这里添加全局 Less 变量
|
||||
modifyVars: {
|
||||
'@primary-color': '#007aff', // macOS 蓝色
|
||||
'@link-color': '#007aff', // 链接色
|
||||
'@border-radius-base': '12px', // 组件圆角
|
||||
'@layout-body-background': '#ffffff', // 白色背景
|
||||
'@component-background': 'rgba(255, 255, 255, 0.8)', // 半透明组件背景
|
||||
'@border-color-base': 'rgba(0, 0, 0, 0.06)', // 边框色
|
||||
// 例如: '@primary-color': '#1DA57A',
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -31,7 +25,7 @@ export default defineConfig({
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8080',
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
|
||||
1893
web/yarn.lock
1893
web/yarn.lock
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user