添加AI评分系统和题目列表功能
新增功能: 1. AI智能评分系统 - 集成OpenAI兼容API进行简答题评分 - 提供分数、评语和改进建议 - 支持自定义AI服务配置(BaseURL、APIKey、Model) 2. 题目列表页面 - 展示所有题目和答案 - Tab标签页形式的题型筛选(选择题、多选题、判断题、填空题、简答题) - 关键词搜索功能(支持题目内容和编号搜索) - 填空题特殊渲染:****显示为下划线 - 判断题不显示选项,界面更简洁 3. UI优化 - 答题结果组件重构,支持AI评分显示 - 首页新增"题目列表"快速入口 - 响应式设计,适配移动端和PC端 技术改进: - 添加AI评分服务层(internal/services/ai_grading.go) - 扩展题目模型支持AI评分结果 - 更新配置管理支持AI服务配置 - 添加AI评分测试脚本和文档 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
91f5e096f8
commit
ea051e9380
119
CLAUDE.md
119
CLAUDE.md
@ -62,8 +62,10 @@ func MiddlewareName() gin.HandlerFunc {
|
|||||||
- **internal/models/** - 数据模型
|
- **internal/models/** - 数据模型
|
||||||
- [user.go](internal/models/user.go) - 用户模型
|
- [user.go](internal/models/user.go) - 用户模型
|
||||||
- [practice_question.go](internal/models/practice_question.go) - 练习题模型
|
- [practice_question.go](internal/models/practice_question.go) - 练习题模型
|
||||||
|
- **internal/services/** - 业务服务
|
||||||
|
- [ai_grading.go](internal/services/ai_grading.go) - AI评分服务
|
||||||
- **internal/database/** - 数据库连接和初始化
|
- **internal/database/** - 数据库连接和初始化
|
||||||
- **pkg/config/** - 配置管理(数据库配置等)
|
- **pkg/config/** - 配置管理(数据库配置、AI配置等)
|
||||||
- **scripts/** - 工具脚本
|
- **scripts/** - 工具脚本
|
||||||
- [import_questions.go](scripts/import_questions.go) - 题目数据导入脚本
|
- [import_questions.go](scripts/import_questions.go) - 题目数据导入脚本
|
||||||
|
|
||||||
@ -119,6 +121,7 @@ go test -v ./...
|
|||||||
- **框架**: 使用 Gin v1.11.0
|
- **框架**: 使用 Gin v1.11.0
|
||||||
- **ORM**: 使用 GORM v1.31.1
|
- **ORM**: 使用 GORM v1.31.1
|
||||||
- **数据库**: PostgreSQL (配置在 [pkg/config/config.go](pkg/config/config.go))
|
- **数据库**: 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) 中配置)
|
- **服务器端口**: :8080 (在 [main.go:42](main.go#L42) 中配置)
|
||||||
- **处理器签名**: 所有处理器使用 `func(c *gin.Context)` 模式
|
- **处理器签名**: 所有处理器使用 `func(c *gin.Context)` 模式
|
||||||
- **JSON 响应**: 使用 `c.JSON()` 方法配合 `gin.H{}` 或结构体
|
- **JSON 响应**: 使用 `c.JSON()` 方法配合 `gin.H{}` 或结构体
|
||||||
@ -126,6 +129,7 @@ go test -v ./...
|
|||||||
- **路由注册**: 路由在 [main.go](main.go) 中使用 `r.GET()`、`r.POST()` 等注册
|
- **路由注册**: 路由在 [main.go](main.go) 中使用 `r.GET()`、`r.POST()` 等注册
|
||||||
- **中间件**: 使用 `r.Use()` 全局应用或通过路由分组应用到特定路由
|
- **中间件**: 使用 `r.Use()` 全局应用或通过路由分组应用到特定路由
|
||||||
- **密码加密**: 使用 bcrypt 加密存储用户密码
|
- **密码加密**: 使用 bcrypt 加密存储用户密码
|
||||||
|
- **AI评分**: 简答题使用AI智能评分,提供分数、评语和改进建议
|
||||||
|
|
||||||
## 添加新功能
|
## 添加新功能
|
||||||
|
|
||||||
@ -180,6 +184,119 @@ go test -v ./...
|
|||||||
- 使用唯一索引防止重复导入
|
- 使用唯一索引防止重复导入
|
||||||
- 大批量导入建议使用事务提高性能
|
- 大批量导入建议使用事务提高性能
|
||||||
|
|
||||||
|
## 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,确保评分结果稳定可靠
|
||||||
|
|
||||||
|
### 自定义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)
|
||||||
|
```
|
||||||
|
|
||||||
## 前端开发规范
|
## 前端开发规范
|
||||||
|
|
||||||
### 包管理和开发
|
### 包管理和开发
|
||||||
|
|||||||
42
README.md
42
README.md
@ -66,7 +66,7 @@ yarn dev
|
|||||||
- `GET /api/practice/questions` - 获取练习题目列表 (支持分页和类型过滤)
|
- `GET /api/practice/questions` - 获取练习题目列表 (支持分页和类型过滤)
|
||||||
- `GET /api/practice/questions/random` - 获取随机练习题目
|
- `GET /api/practice/questions/random` - 获取随机练习题目
|
||||||
- `GET /api/practice/questions/:id` - 获取指定练习题目
|
- `GET /api/practice/questions/:id` - 获取指定练习题目
|
||||||
- `POST /api/practice/submit` - 提交练习答案
|
- `POST /api/practice/submit` - 提交练习答案 (简答题自动AI评分)
|
||||||
- `GET /api/practice/types` - 获取题型列表
|
- `GET /api/practice/types` - 获取题型列表
|
||||||
|
|
||||||
#### 其他
|
#### 其他
|
||||||
@ -105,6 +105,7 @@ go build -o bin/server.exe main.go
|
|||||||
- 用户登录系统(基于PostgreSQL数据库)
|
- 用户登录系统(基于PostgreSQL数据库)
|
||||||
- 题目练习功能
|
- 题目练习功能
|
||||||
- 答题统计功能
|
- 答题统计功能
|
||||||
|
- **AI智能评分** - 简答题使用AI进行智能评分和反馈
|
||||||
- React + TypeScript + Vite 前端
|
- React + TypeScript + Vite 前端
|
||||||
- Ant Design Mobile UI组件库
|
- Ant Design Mobile UI组件库
|
||||||
|
|
||||||
@ -158,7 +159,42 @@ go run scripts/import_questions.go
|
|||||||
- 判断题 (80道)
|
- 判断题 (80道)
|
||||||
- 单选题 (40道)
|
- 单选题 (40道)
|
||||||
- 多选题 (30道)
|
- 多选题 (30道)
|
||||||
- 简答题 (6道)
|
- 简答题 (6道) - **支持AI智能评分**
|
||||||
|
|
||||||
|
## 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="你的模型名称"
|
||||||
|
```
|
||||||
|
|
||||||
|
### AI评分返回格式
|
||||||
|
对简答题提交答案时,响应会包含 `ai_grading` 字段:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"correct": true,
|
||||||
|
"user_answer": "用户的答案",
|
||||||
|
"correct_answer": "标准答案",
|
||||||
|
"ai_grading": {
|
||||||
|
"score": 85,
|
||||||
|
"feedback": "答案基本正确,要点全面",
|
||||||
|
"suggestion": "可以补充一些具体的例子"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## 前端开发
|
## 前端开发
|
||||||
|
|
||||||
@ -203,6 +239,7 @@ yarn build
|
|||||||
- 练习题管理系统(236道练习题,5种题型)
|
- 练习题管理系统(236道练习题,5种题型)
|
||||||
- 支持分页查询和题型筛选
|
- 支持分页查询和题型筛选
|
||||||
- 随机题目推送功能
|
- 随机题目推送功能
|
||||||
|
- **AI智能评分系统** - 使用deepseek-v3对简答题进行智能评分和反馈
|
||||||
|
|
||||||
### 前端特性
|
### 前端特性
|
||||||
- React + TypeScript + Vite 技术栈
|
- React + TypeScript + Vite 技术栈
|
||||||
@ -220,6 +257,7 @@ yarn build
|
|||||||
- **GORM** v1.31.1 - ORM框架
|
- **GORM** v1.31.1 - ORM框架
|
||||||
- **PostgreSQL** - 数据库
|
- **PostgreSQL** - 数据库
|
||||||
- **bcrypt** - 密码加密
|
- **bcrypt** - 密码加密
|
||||||
|
- **go-openai** v1.41.2 - OpenAI SDK (用于AI评分)
|
||||||
|
|
||||||
### 前端
|
### 前端
|
||||||
- **React** 18 - UI框架
|
- **React** 18 - UI框架
|
||||||
|
|||||||
1
go.mod
1
go.mod
@ -36,6 +36,7 @@ require (
|
|||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/quic-go/qpack v0.5.1 // indirect
|
github.com/quic-go/qpack v0.5.1 // indirect
|
||||||
github.com/quic-go/quic-go v0.55.0 // indirect
|
github.com/quic-go/quic-go v0.55.0 // indirect
|
||||||
|
github.com/sashabaranov/go-openai v1.41.2 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
go.uber.org/mock v0.6.0 // indirect
|
go.uber.org/mock v0.6.0 // indirect
|
||||||
|
|||||||
2
go.sum
2
go.sum
@ -63,6 +63,8 @@ github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
|||||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||||
github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
|
github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
|
||||||
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
|
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
|
||||||
|
github.com/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.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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"ankao/internal/database"
|
"ankao/internal/database"
|
||||||
"ankao/internal/models"
|
"ankao/internal/models"
|
||||||
|
"ankao/internal/services"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@ -170,6 +171,36 @@ func SubmitPracticeAnswer(c *gin.Context) {
|
|||||||
// 验证答案
|
// 验证答案
|
||||||
correct := checkPracticeAnswer(question.Type, submit.Answer, correctAnswer)
|
correct := checkPracticeAnswer(question.Type, submit.Answer, correctAnswer)
|
||||||
|
|
||||||
|
// AI评分结果(仅简答题)
|
||||||
|
var aiGrading *models.AIGrading = nil
|
||||||
|
|
||||||
|
// 对简答题使用AI评分
|
||||||
|
if question.Type == "short-answer" {
|
||||||
|
// 获取用户答案字符串
|
||||||
|
userAnswerStr, ok := submit.Answer.(string)
|
||||||
|
if ok {
|
||||||
|
// 获取标准答案字符串
|
||||||
|
standardAnswerStr, ok := correctAnswer.(string)
|
||||||
|
if ok {
|
||||||
|
// 调用AI评分服务
|
||||||
|
aiService := services.NewAIGradingService()
|
||||||
|
aiResult, err := aiService.GradeShortAnswer(question.Question, standardAnswerStr, userAnswerStr)
|
||||||
|
if err != nil {
|
||||||
|
// AI评分失败时记录日志,但不影响主流程
|
||||||
|
log.Printf("AI评分失败: %v", err)
|
||||||
|
} else {
|
||||||
|
// 使用AI的评分结果
|
||||||
|
correct = aiResult.IsCorrect
|
||||||
|
aiGrading = &models.AIGrading{
|
||||||
|
Score: aiResult.Score,
|
||||||
|
Feedback: aiResult.Feedback,
|
||||||
|
Suggestion: aiResult.Suggestion,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 记录用户答题历史
|
// 记录用户答题历史
|
||||||
if uid, ok := userID.(uint); ok {
|
if uid, ok := userID.(uint); ok {
|
||||||
record := models.UserAnswerRecord{
|
record := models.UserAnswerRecord{
|
||||||
@ -199,6 +230,7 @@ func SubmitPracticeAnswer(c *gin.Context) {
|
|||||||
Correct: correct,
|
Correct: correct,
|
||||||
UserAnswer: submit.Answer,
|
UserAnswer: submit.Answer,
|
||||||
CorrectAnswer: correctAnswer, // 始终返回正确答案
|
CorrectAnswer: correctAnswer, // 始终返回正确答案
|
||||||
|
AIGrading: aiGrading, // AI评分结果(仅简答题有值)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
|||||||
@ -40,4 +40,12 @@ type PracticeAnswerResult struct {
|
|||||||
Correct bool `json:"correct"` // 是否正确
|
Correct bool `json:"correct"` // 是否正确
|
||||||
UserAnswer interface{} `json:"user_answer"` // 用户答案
|
UserAnswer interface{} `json:"user_answer"` // 用户答案
|
||||||
CorrectAnswer interface{} `json:"correct_answer,omitempty"` // 正确答案(仅在错误时返回)
|
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"` // 改进建议
|
||||||
}
|
}
|
||||||
|
|||||||
156
internal/services/ai_grading.go
Normal file
156
internal/services/ai_grading.go
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"ankao/pkg/config"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sashabaranov/go-openai"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AIGradingService AI评分服务
|
||||||
|
type AIGradingService struct {
|
||||||
|
client *openai.Client
|
||||||
|
config *config.AIConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAIGradingService 创建AI评分服务实例
|
||||||
|
func NewAIGradingService() *AIGradingService {
|
||||||
|
cfg := config.GetAIConfig()
|
||||||
|
|
||||||
|
// 创建OpenAI客户端配置
|
||||||
|
clientConfig := openai.DefaultConfig(cfg.APIKey)
|
||||||
|
clientConfig.BaseURL = cfg.BaseURL + "/v1" // 标准OpenAI API格式需要/v1后缀
|
||||||
|
|
||||||
|
client := openai.NewClientWithConfig(clientConfig)
|
||||||
|
|
||||||
|
return &AIGradingService{
|
||||||
|
client: client,
|
||||||
|
config: cfg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"` // 改进建议
|
||||||
|
}
|
||||||
|
|
||||||
|
// GradeShortAnswer 对简答题进行AI评分
|
||||||
|
// question: 题目内容
|
||||||
|
// standardAnswer: 标准答案
|
||||||
|
// userAnswer: 用户答案
|
||||||
|
func (s *AIGradingService) GradeShortAnswer(question, standardAnswer, userAnswer string) (*AIGradingResult, error) {
|
||||||
|
// 构建评分提示词
|
||||||
|
prompt := fmt.Sprintf(`你是一位专业的阅卷老师,请对以下简答题进行评分。
|
||||||
|
|
||||||
|
题目:%s
|
||||||
|
|
||||||
|
标准答案:%s
|
||||||
|
|
||||||
|
学生答案:%s
|
||||||
|
|
||||||
|
请按照以下要求进行评分:
|
||||||
|
1. 给出一个0-100的分数
|
||||||
|
2. 判断答案是否正确(60分及以上为正确)
|
||||||
|
3. 给出简短的评语(不超过50字)
|
||||||
|
4. 如果答案不完善,给出改进建议(不超过50字,如果答案很好可以为空)
|
||||||
|
|
||||||
|
请按照以下JSON格式返回结果:
|
||||||
|
{
|
||||||
|
"score": 85,
|
||||||
|
"is_correct": true,
|
||||||
|
"feedback": "答案基本正确,要点全面",
|
||||||
|
"suggestion": "可以补充一些具体的例子"
|
||||||
|
}
|
||||||
|
|
||||||
|
注意:只返回JSON格式的结果,不要有其他内容。`, question, standardAnswer, userAnswer)
|
||||||
|
|
||||||
|
// 调用AI API
|
||||||
|
resp, err := s.client.CreateChatCompletion(
|
||||||
|
context.Background(),
|
||||||
|
openai.ChatCompletionRequest{
|
||||||
|
Model: s.config.Model,
|
||||||
|
Messages: []openai.ChatCompletionMessage{
|
||||||
|
{
|
||||||
|
Role: openai.ChatMessageRoleSystem,
|
||||||
|
Content: "你是一位专业的阅卷老师,擅长对简答题进行公正、客观的评分。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Role: openai.ChatMessageRoleUser,
|
||||||
|
Content: prompt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Temperature: 0.3, // 较低的温度以获得更稳定的评分结果
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("AI评分失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.Choices) == 0 {
|
||||||
|
return nil, fmt.Errorf("AI未返回评分结果")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析AI返回的JSON结果
|
||||||
|
content := resp.Choices[0].Message.Content
|
||||||
|
|
||||||
|
var result AIGradingResult
|
||||||
|
if err := parseAIResponse(content, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析AI响应失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAIResponse 解析AI返回的JSON响应
|
||||||
|
func parseAIResponse(content string, result *AIGradingResult) 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]
|
||||||
|
}
|
||||||
@ -16,6 +16,13 @@ type DatabaseConfig struct {
|
|||||||
SSLMode string
|
SSLMode string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AIConfig AI服务配置结构
|
||||||
|
type AIConfig struct {
|
||||||
|
BaseURL string
|
||||||
|
APIKey string
|
||||||
|
Model string
|
||||||
|
}
|
||||||
|
|
||||||
// GetDatabaseConfig 获取数据库配置
|
// GetDatabaseConfig 获取数据库配置
|
||||||
// 优先使用环境变量,如果没有设置则使用默认值
|
// 优先使用环境变量,如果没有设置则使用默认值
|
||||||
func GetDatabaseConfig() *DatabaseConfig {
|
func GetDatabaseConfig() *DatabaseConfig {
|
||||||
@ -67,3 +74,17 @@ func (c *DatabaseConfig) GetDSN() string {
|
|||||||
c.SSLMode,
|
c.SSLMode,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAIConfig 获取AI服务配置
|
||||||
|
// 优先使用环境变量,如果没有设置则使用默认值
|
||||||
|
func GetAIConfig() *AIConfig {
|
||||||
|
baseURL := getEnv("AI_BASE_URL", "https://ai.yuchat.top")
|
||||||
|
apiKey := getEnv("AI_API_KEY", "sk-OKBmOpJx855juSOPU14cWG6Iz87tZQuv3Xg9PiaJYXdHoKcN")
|
||||||
|
model := getEnv("AI_MODEL", "deepseek-v3")
|
||||||
|
|
||||||
|
return &AIConfig{
|
||||||
|
BaseURL: baseURL,
|
||||||
|
APIKey: apiKey,
|
||||||
|
Model: model,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
204
test_ai_grading.md
Normal file
204
test_ai_grading.md
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
# AI 评分功能测试指南
|
||||||
|
|
||||||
|
## 测试前提
|
||||||
|
|
||||||
|
1. 确保后端服务器运行在 `http://localhost:8080`
|
||||||
|
2. 确保数据库中有简答题数据
|
||||||
|
3. 需要先登录获取 token
|
||||||
|
|
||||||
|
## 测试步骤
|
||||||
|
|
||||||
|
### 1. 登录获取 Token
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8080/api/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"username": "你的用户名",
|
||||||
|
"password": "你的密码"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
返回示例:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"token": "your-token-here",
|
||||||
|
"user": {...}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 获取简答题列表
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET "http://localhost:8080/api/practice/questions?type=short-answer" \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
找到一个简答题的 ID(例如:123)
|
||||||
|
|
||||||
|
### 3. 提交简答题答案测试 AI 评分
|
||||||
|
|
||||||
|
**测试用例 1:提交一个接近标准答案的回答**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8080/api/practice/submit \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-d '{
|
||||||
|
"question_id": 123,
|
||||||
|
"answer": "网络安全是保护计算机网络系统及其数据的完整性、保密性和可用性的措施"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**预期返回**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"correct": true,
|
||||||
|
"user_answer": "网络安全是保护计算机网络系统及其数据的完整性、保密性和可用性的措施",
|
||||||
|
"correct_answer": "标准答案...",
|
||||||
|
"ai_grading": {
|
||||||
|
"score": 85,
|
||||||
|
"feedback": "答案基本正确,要点全面",
|
||||||
|
"suggestion": "可以补充一些具体的例子"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**测试用例 2:提交一个不完整的回答**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8080/api/practice/submit \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-d '{
|
||||||
|
"question_id": 123,
|
||||||
|
"answer": "保护网络安全"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**预期返回**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"correct": false,
|
||||||
|
"user_answer": "保护网络安全",
|
||||||
|
"correct_answer": "标准答案...",
|
||||||
|
"ai_grading": {
|
||||||
|
"score": 45,
|
||||||
|
"feedback": "答案过于简单,缺少要点",
|
||||||
|
"suggestion": "需要补充完整性、保密性和可用性等关键概念"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 验证要点
|
||||||
|
|
||||||
|
1. **响应结构**:检查响应中是否包含 `ai_grading` 字段
|
||||||
|
2. **评分合理性**:AI 给出的分数是否在 0-100 之间
|
||||||
|
3. **correct 字段**:分数 >= 60 时,correct 应该为 true
|
||||||
|
4. **反馈信息**:feedback 和 suggestion 是否有意义
|
||||||
|
5. **非简答题**:对其他题型(填空题、判断题等),`ai_grading` 字段应该为 null
|
||||||
|
|
||||||
|
## 使用 Postman 测试
|
||||||
|
|
||||||
|
1. 创建新请求,方法为 POST
|
||||||
|
2. URL:`http://localhost:8080/api/practice/submit`
|
||||||
|
3. Headers 添加:
|
||||||
|
- `Content-Type: application/json`
|
||||||
|
- `Authorization: Bearer YOUR_TOKEN`
|
||||||
|
4. Body 选择 raw 和 JSON,输入:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"question_id": 123,
|
||||||
|
"answer": "你的答案"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
5. 点击 Send 发送请求
|
||||||
|
6. 查看响应中的 `ai_grading` 字段
|
||||||
|
|
||||||
|
## 前端测试
|
||||||
|
|
||||||
|
如果你已经实现了前端界面:
|
||||||
|
|
||||||
|
1. 登录系统
|
||||||
|
2. 进入练习页面
|
||||||
|
3. 筛选简答题类型
|
||||||
|
4. 随机获取一道简答题
|
||||||
|
5. 输入答案并提交
|
||||||
|
6. 查看是否显示 AI 评分结果(分数、评语、建议)
|
||||||
|
|
||||||
|
## 故障排查
|
||||||
|
|
||||||
|
### 问题 1:没有返回 ai_grading 字段
|
||||||
|
|
||||||
|
**可能原因**:
|
||||||
|
- 题目类型不是 `short-answer`
|
||||||
|
- 服务器未重启,使用的是旧代码
|
||||||
|
|
||||||
|
**解决方法**:
|
||||||
|
- 确认题目类型:`GET /api/practice/questions/:id`
|
||||||
|
- 重新编译并启动服务器:
|
||||||
|
```bash
|
||||||
|
go build -o bin/server.exe main.go
|
||||||
|
./bin/server.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
### 问题 2:AI 评分失败
|
||||||
|
|
||||||
|
**可能原因**:
|
||||||
|
- API 密钥无效
|
||||||
|
- 网络连接问题
|
||||||
|
- AI 服务不可用
|
||||||
|
|
||||||
|
**解决方法**:
|
||||||
|
- 检查后端日志中的错误信息
|
||||||
|
- 验证 API 配置:查看 [pkg/config/config.go](pkg/config/config.go)
|
||||||
|
- 尝试直接调用 AI API 测试连通性
|
||||||
|
|
||||||
|
### 问题 3:评分结果不合理
|
||||||
|
|
||||||
|
**可能原因**:
|
||||||
|
- 提示词需要优化
|
||||||
|
- AI 模型理解偏差
|
||||||
|
|
||||||
|
**解决方法**:
|
||||||
|
- 调整 [internal/services/ai_grading.go](internal/services/ai_grading.go) 中的提示词
|
||||||
|
- 增加更详细的评分标准说明
|
||||||
|
|
||||||
|
## 测试数据示例
|
||||||
|
|
||||||
|
以下是一些可以用于测试的简答题和答案:
|
||||||
|
|
||||||
|
### 题目:什么是网络安全?
|
||||||
|
|
||||||
|
**优秀答案(应得高分)**:
|
||||||
|
"网络安全是指保护计算机网络系统及其数据免受未经授权的访问、使用、披露、破坏、修改或破坏的技术、策略和实践。它包括保护网络的完整性、保密性和可用性,确保数据在传输和存储过程中的安全。"
|
||||||
|
|
||||||
|
**一般答案(应得中等分)**:
|
||||||
|
"网络安全是保护计算机和网络不被黑客攻击,保护数据安全。"
|
||||||
|
|
||||||
|
**较差答案(应得低分)**:
|
||||||
|
"网络安全就是网络很安全。"
|
||||||
|
|
||||||
|
## 日志查看
|
||||||
|
|
||||||
|
查看服务器日志中的 AI 评分相关信息:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Windows (如果使用 PowerShell)
|
||||||
|
Get-Content .\logs\server.log -Wait
|
||||||
|
|
||||||
|
# 或直接查看控制台输出
|
||||||
|
```
|
||||||
|
|
||||||
|
关注以下日志:
|
||||||
|
- `AI评分失败: ...` - AI 评分出错
|
||||||
|
- 没有错误日志 - AI 评分成功
|
||||||
107
test_ai_grading.ps1
Normal file
107
test_ai_grading.ps1
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
# AI评分功能测试脚本 (PowerShell版本)
|
||||||
|
# 请先登录获取token,并将token替换下面的$Token变量
|
||||||
|
|
||||||
|
# 配置
|
||||||
|
$BaseUrl = "http://localhost:8080"
|
||||||
|
$Token = "YOUR_TOKEN" # 请替换为你的实际token
|
||||||
|
$QuestionId = 465 # 简答题ID(第一道题)
|
||||||
|
|
||||||
|
Write-Host "=========================================" -ForegroundColor Green
|
||||||
|
Write-Host "AI评分功能测试" -ForegroundColor Green
|
||||||
|
Write-Host "=========================================" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# 测试1: 提交一个完整的答案
|
||||||
|
Write-Host "测试1: 提交完整答案(应该得高分)" -ForegroundColor Yellow
|
||||||
|
Write-Host "-----------------------------------------" -ForegroundColor Yellow
|
||||||
|
|
||||||
|
$Body1 = @{
|
||||||
|
question_id = 465
|
||||||
|
answer = "《中华人民共和国保守国家秘密法》第四十八条列举了十二种违法行为,主要包括:非法获取、持有国家秘密载体;买卖、转送或私自销毁秘密载体;通过无保密措施渠道传递秘密;未经批准携带秘密载体出境;非法复制、记录、存储国家秘密;在私人交往中涉及秘密;在互联网传递秘密;将涉密计算机接入公网;在涉密系统与公网间交换信息;使用非涉密设备处理秘密;擅自修改安全程序;以及将退出使用的涉密设备改作他用等。"
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
||||||
|
$Headers = @{
|
||||||
|
"Content-Type" = "application/json"
|
||||||
|
"Authorization" = "Bearer $Token"
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$Response1 = Invoke-RestMethod -Uri "$BaseUrl/api/practice/submit" -Method Post -Headers $Headers -Body $Body1
|
||||||
|
Write-Host "响应结果:" -ForegroundColor Cyan
|
||||||
|
$Response1 | ConvertTo-Json -Depth 10 | Write-Host
|
||||||
|
|
||||||
|
if ($Response1.data.ai_grading) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "AI评分结果:" -ForegroundColor Green
|
||||||
|
Write-Host " 分数: $($Response1.data.ai_grading.score)" -ForegroundColor Green
|
||||||
|
Write-Host " 是否正确: $($Response1.data.correct)" -ForegroundColor Green
|
||||||
|
Write-Host " 评语: $($Response1.data.ai_grading.feedback)" -ForegroundColor Green
|
||||||
|
Write-Host " 建议: $($Response1.data.ai_grading.suggestion)" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host "错误: $_" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# 测试2: 提交一个简短的答案
|
||||||
|
Write-Host "测试2: 提交简短答案(应该得中等分数)" -ForegroundColor Yellow
|
||||||
|
Write-Host "-----------------------------------------" -ForegroundColor Yellow
|
||||||
|
|
||||||
|
$Body2 = @{
|
||||||
|
question_id = 465
|
||||||
|
answer = "主要包括非法获取国家秘密、买卖秘密载体、通过互联网传递秘密、将涉密设备接入公网等违法行为。"
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
||||||
|
try {
|
||||||
|
$Response2 = Invoke-RestMethod -Uri "$BaseUrl/api/practice/submit" -Method Post -Headers $Headers -Body $Body2
|
||||||
|
Write-Host "响应结果:" -ForegroundColor Cyan
|
||||||
|
$Response2 | ConvertTo-Json -Depth 10 | Write-Host
|
||||||
|
|
||||||
|
if ($Response2.data.ai_grading) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "AI评分结果:" -ForegroundColor Green
|
||||||
|
Write-Host " 分数: $($Response2.data.ai_grading.score)" -ForegroundColor Green
|
||||||
|
Write-Host " 是否正确: $($Response2.data.correct)" -ForegroundColor Green
|
||||||
|
Write-Host " 评语: $($Response2.data.ai_grading.feedback)" -ForegroundColor Green
|
||||||
|
Write-Host " 建议: $($Response2.data.ai_grading.suggestion)" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host "错误: $_" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# 测试3: 提交一个不完整的答案
|
||||||
|
Write-Host "测试3: 提交不完整答案(应该得低分)" -ForegroundColor Yellow
|
||||||
|
Write-Host "-----------------------------------------" -ForegroundColor Yellow
|
||||||
|
|
||||||
|
$Body3 = @{
|
||||||
|
question_id = 465
|
||||||
|
answer = "不能泄露国家秘密,不能在网上传播秘密信息。"
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
||||||
|
try {
|
||||||
|
$Response3 = Invoke-RestMethod -Uri "$BaseUrl/api/practice/submit" -Method Post -Headers $Headers -Body $Body3
|
||||||
|
Write-Host "响应结果:" -ForegroundColor Cyan
|
||||||
|
$Response3 | ConvertTo-Json -Depth 10 | Write-Host
|
||||||
|
|
||||||
|
if ($Response3.data.ai_grading) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "AI评分结果:" -ForegroundColor Green
|
||||||
|
Write-Host " 分数: $($Response3.data.ai_grading.score)" -ForegroundColor Green
|
||||||
|
Write-Host " 是否正确: $($Response3.data.correct)" -ForegroundColor Green
|
||||||
|
Write-Host " 评语: $($Response3.data.ai_grading.feedback)" -ForegroundColor Green
|
||||||
|
Write-Host " 建议: $($Response3.data.ai_grading.suggestion)" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host "错误: $_" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "=========================================" -ForegroundColor Green
|
||||||
|
Write-Host "测试完成" -ForegroundColor Green
|
||||||
|
Write-Host "=========================================" -ForegroundColor Green
|
||||||
59
test_ai_grading.sh
Normal file
59
test_ai_grading.sh
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# AI评分功能测试脚本
|
||||||
|
# 请先登录获取token,并将token替换下面的YOUR_TOKEN
|
||||||
|
|
||||||
|
# 配置
|
||||||
|
BASE_URL="http://localhost:8080"
|
||||||
|
TOKEN="YOUR_TOKEN" # 请替换为你的实际token
|
||||||
|
QUESTION_ID=465 # 简答题ID(第一道题)
|
||||||
|
|
||||||
|
echo "========================================="
|
||||||
|
echo "AI评分功能测试"
|
||||||
|
echo "========================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 测试1: 提交一个完整的答案
|
||||||
|
echo "测试1: 提交完整答案(应该得高分)"
|
||||||
|
echo "-----------------------------------------"
|
||||||
|
curl -X POST "${BASE_URL}/api/practice/submit" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer ${TOKEN}" \
|
||||||
|
-d '{
|
||||||
|
"question_id": 465,
|
||||||
|
"answer": "《中华人民共和国保守国家秘密法》第四十八条列举了十二种违法行为,主要包括:非法获取、持有国家秘密载体;买卖、转送或私自销毁秘密载体;通过无保密措施渠道传递秘密;未经批准携带秘密载体出境;非法复制、记录、存储国家秘密;在私人交往中涉及秘密;在互联网传递秘密;将涉密计算机接入公网;在涉密系统与公网间交换信息;使用非涉密设备处理秘密;擅自修改安全程序;以及将退出使用的涉密设备改作他用等。"
|
||||||
|
}' | python -m json.tool
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 测试2: 提交一个简短的答案
|
||||||
|
echo "测试2: 提交简短答案(应该得中等分数)"
|
||||||
|
echo "-----------------------------------------"
|
||||||
|
curl -X POST "${BASE_URL}/api/practice/submit" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer ${TOKEN}" \
|
||||||
|
-d '{
|
||||||
|
"question_id": 465,
|
||||||
|
"answer": "主要包括非法获取国家秘密、买卖秘密载体、通过互联网传递秘密、将涉密设备接入公网等违法行为。"
|
||||||
|
}' | python -m json.tool
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 测试3: 提交一个不完整的答案
|
||||||
|
echo "测试3: 提交不完整答案(应该得低分)"
|
||||||
|
echo "-----------------------------------------"
|
||||||
|
curl -X POST "${BASE_URL}/api/practice/submit" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer ${TOKEN}" \
|
||||||
|
-d '{
|
||||||
|
"question_id": 465,
|
||||||
|
"answer": "不能泄露国家秘密,不能在网上传播秘密信息。"
|
||||||
|
}' | python -m json.tool
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo ""
|
||||||
|
echo "========================================="
|
||||||
|
echo "测试完成"
|
||||||
|
echo "========================================="
|
||||||
@ -9,6 +9,7 @@ import Home from './pages/Home'
|
|||||||
import About from './pages/About'
|
import About from './pages/About'
|
||||||
import WrongQuestions from './pages/WrongQuestions'
|
import WrongQuestions from './pages/WrongQuestions'
|
||||||
import QuestionManagement from './pages/QuestionManagement'
|
import QuestionManagement from './pages/QuestionManagement'
|
||||||
|
import QuestionList from './pages/QuestionList'
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
@ -22,6 +23,7 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
{/* 不带TabBar的页面,但需要登录保护 */}
|
{/* 不带TabBar的页面,但需要登录保护 */}
|
||||||
<Route path="/wrong-questions" element={<ProtectedRoute><WrongQuestions /></ProtectedRoute>} />
|
<Route path="/wrong-questions" element={<ProtectedRoute><WrongQuestions /></ProtectedRoute>} />
|
||||||
|
<Route path="/question-list" element={<ProtectedRoute><QuestionList /></ProtectedRoute>} />
|
||||||
|
|
||||||
{/* 题库管理页面,需要管理员权限 */}
|
{/* 题库管理页面,需要管理员权限 */}
|
||||||
<Route path="/question-management" element={
|
<Route path="/question-management" element={
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Alert, Typography } from 'antd'
|
import { Alert, Typography, Card, Space, Progress } from 'antd'
|
||||||
import { CheckOutlined, CloseOutlined } from '@ant-design/icons'
|
import { CheckOutlined, CloseOutlined, TrophyOutlined, CommentOutlined, BulbOutlined } from '@ant-design/icons'
|
||||||
import type { AnswerResult as AnswerResultType } from '../types/question'
|
import type { AnswerResult as AnswerResultType } from '../types/question'
|
||||||
|
|
||||||
const { Text } = Typography
|
const { Text, Paragraph } = Typography
|
||||||
|
|
||||||
interface AnswerResultProps {
|
interface AnswerResultProps {
|
||||||
answerResult: AnswerResultType
|
answerResult: AnswerResultType
|
||||||
@ -36,7 +36,24 @@ const AnswerResult: React.FC<AnswerResultProps> = ({
|
|||||||
return String(answer)
|
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 (
|
return (
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
<Alert
|
<Alert
|
||||||
type={answerResult.correct ? 'success' : 'error'}
|
type={answerResult.correct ? 'success' : 'error'}
|
||||||
icon={answerResult.correct ? <CheckOutlined /> : <CloseOutlined />}
|
icon={answerResult.correct ? <CheckOutlined /> : <CloseOutlined />}
|
||||||
@ -50,7 +67,7 @@ const AnswerResult: React.FC<AnswerResultProps> = ({
|
|||||||
<div style={{ marginBottom: 8 }}>
|
<div style={{ marginBottom: 8 }}>
|
||||||
<Text type="secondary">你的答案:</Text>
|
<Text type="secondary">你的答案:</Text>
|
||||||
<Text strong={answerResult.correct} type={answerResult.correct ? undefined : 'danger'}>
|
<Text strong={answerResult.correct} type={answerResult.correct ? undefined : 'danger'}>
|
||||||
{formatAnswer(selectedAnswer)}
|
{formatAnswer(answerResult.user_answer || selectedAnswer)}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginBottom: 8 }}>
|
<div style={{ marginBottom: 8 }}>
|
||||||
@ -71,8 +88,83 @@ const AnswerResult: React.FC<AnswerResultProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
style={{ marginTop: 20 }}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 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.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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
UserOutlined,
|
UserOutlined,
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
|
UnorderedListOutlined as ListOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import * as questionApi from '../api/question'
|
import * as questionApi from '../api/question'
|
||||||
import type { Statistics } from '../types/question'
|
import type { Statistics } from '../types/question'
|
||||||
@ -275,6 +276,24 @@ const Home: React.FC = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} sm={24} md={12} lg={8}>
|
||||||
|
<Card
|
||||||
|
hoverable
|
||||||
|
className={styles.quickCard}
|
||||||
|
onClick={() => navigate('/question-list')}
|
||||||
|
>
|
||||||
|
<Space align="center" size="middle" style={{ width: '100%' }}>
|
||||||
|
<div className={styles.quickIcon}>
|
||||||
|
<ListOutlined style={{ fontSize: '32px', color: '#1677ff' }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<Title level={5} style={{ margin: 0 }}>题目列表</Title>
|
||||||
|
<Paragraph type="secondary" style={{ margin: 0, fontSize: '13px' }}>查看所有题目和答案</Paragraph>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
{/* 仅 yanlongqi 用户显示题库管理 */}
|
{/* 仅 yanlongqi 用户显示题库管理 */}
|
||||||
{userInfo?.username === 'yanlongqi' && (
|
{userInfo?.username === 'yanlongqi' && (
|
||||||
<Col xs={24} sm={24} md={12} lg={8}>
|
<Col xs={24} sm={24} md={12} lg={8}>
|
||||||
|
|||||||
133
web/src/pages/QuestionList.module.less
Normal file
133
web/src/pages/QuestionList.module.less
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
.container {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backButton {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0 !important;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterCard {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchInput {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabsCard {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
:global {
|
||||||
|
.ant-tabs-nav {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.questionCard {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.questionHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.questionId {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.questionNumber {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.questionContent {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contentText {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
color: #262626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blank {
|
||||||
|
display: inline-block;
|
||||||
|
border-bottom: 2px solid #1677ff;
|
||||||
|
min-width: 60px;
|
||||||
|
margin: 0 4px;
|
||||||
|
color: transparent;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.options {
|
||||||
|
background-color: #fafafa;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 0;
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.answerSection {
|
||||||
|
background-color: #f6ffed;
|
||||||
|
border: 1px solid #b7eb8f;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answerText {
|
||||||
|
margin: 8px 0 0 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #262626;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.questionHeader {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.questionNumber {
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
283
web/src/pages/QuestionList.tsx
Normal file
283
web/src/pages/QuestionList.tsx
Normal file
@ -0,0 +1,283 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Tabs,
|
||||||
|
List,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
Space,
|
||||||
|
Divider,
|
||||||
|
Button,
|
||||||
|
Spin,
|
||||||
|
message,
|
||||||
|
Input,
|
||||||
|
} from 'antd'
|
||||||
|
import {
|
||||||
|
BookOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
CloseCircleOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
UnorderedListOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
ArrowLeftOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import * as questionApi from '../api/question'
|
||||||
|
import type { Question } from '../types/question'
|
||||||
|
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' },
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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): React.ReactNode => {
|
||||||
|
// 将 **** 替换为下划线样式
|
||||||
|
const parts = content.split('****')
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{parts.map((part, index) => (
|
||||||
|
<React.Fragment key={index}>
|
||||||
|
{part}
|
||||||
|
{index < parts.length - 1 && (
|
||||||
|
<span className={styles.blank}>______</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
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.options}>
|
||||||
|
{question.options.map(opt => (
|
||||||
|
<div key={opt.key} className={styles.option}>
|
||||||
|
<Tag color="blue">{opt.key}</Tag>
|
||||||
|
<Text>{opt.value}</Text>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{/* 头部 */}
|
||||||
|
<div className={styles.header}>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<ArrowLeftOutlined />}
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className={styles.backButton}
|
||||||
|
>
|
||||||
|
返回
|
||||||
|
</Button>
|
||||||
|
<Title level={3} className={styles.title}>
|
||||||
|
<BookOutlined /> 题目列表
|
||||||
|
</Title>
|
||||||
|
</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) => {
|
||||||
|
const typeConfig = questionTypeConfig[question.type]
|
||||||
|
return (
|
||||||
|
<Card key={question.id} className={styles.questionCard}>
|
||||||
|
{/* 题目头部 */}
|
||||||
|
<div className={styles.questionHeader}>
|
||||||
|
<Space size="small">
|
||||||
|
<Tag color={typeConfig?.color || 'default'}>
|
||||||
|
{typeConfig?.icon} {typeConfig?.label || question.type}
|
||||||
|
</Tag>
|
||||||
|
<Text type="secondary" className={styles.questionId}>
|
||||||
|
#{question.question_id}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
<Text type="secondary" className={styles.questionNumber}>
|
||||||
|
第 {index + 1} 题
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 题目内容 */}
|
||||||
|
<div className={styles.questionContent}>
|
||||||
|
<Paragraph className={styles.contentText}>
|
||||||
|
{question.type === 'fill-in-blank'
|
||||||
|
? renderFillInBlankContent(question.content)
|
||||||
|
: question.content
|
||||||
|
}
|
||||||
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 选项 */}
|
||||||
|
{renderOptions(question)}
|
||||||
|
|
||||||
|
{/* 答案 */}
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default QuestionList
|
||||||
@ -24,11 +24,20 @@ export interface SubmitAnswer {
|
|||||||
answer: string | string[] | boolean
|
answer: string | string[] | boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AI评分结果
|
||||||
|
export interface AIGrading {
|
||||||
|
score: number // 得分 (0-100)
|
||||||
|
feedback: string // 评语
|
||||||
|
suggestion: string // 改进建议
|
||||||
|
}
|
||||||
|
|
||||||
// 答案结果
|
// 答案结果
|
||||||
export interface AnswerResult {
|
export interface AnswerResult {
|
||||||
correct: boolean
|
correct: boolean
|
||||||
|
user_answer: string | string[] | boolean
|
||||||
correct_answer: string | string[]
|
correct_answer: string | string[]
|
||||||
explanation?: string
|
explanation?: string
|
||||||
|
ai_grading?: AIGrading // AI评分结果(仅简答题)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 统计数据
|
// 统计数据
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user