添加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:
yanlongqi 2025-11-05 13:36:30 +08:00
parent 91f5e096f8
commit ea051e9380
17 changed files with 1321 additions and 38 deletions

119
CLAUDE.md
View File

@ -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)
```
## 前端开发规范 ## 前端开发规范
### 包管理和开发 ### 包管理和开发

View File

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

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

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

View File

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

View File

@ -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"` // 改进建议
} }

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

View File

@ -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
View 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
```
### 问题 2AI 评分失败
**可能原因**
- 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
View 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
View 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 "========================================="

View File

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

View File

@ -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,43 +36,135 @@ 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 (
<Alert <div style={{ marginTop: 20 }}>
type={answerResult.correct ? 'success' : 'error'} <Alert
icon={answerResult.correct ? <CheckOutlined /> : <CloseOutlined />} type={answerResult.correct ? 'success' : 'error'}
message={ icon={answerResult.correct ? <CheckOutlined /> : <CloseOutlined />}
<div> message={
<strong>{answerResult.correct ? '回答正确!' : '回答错误'}</strong> <div>
</div> <strong>{answerResult.correct ? '回答正确!' : '回答错误'}</strong>
}
description={
<div>
<div style={{ marginBottom: 8 }}>
<Text type="secondary"></Text>
<Text strong={answerResult.correct} type={answerResult.correct ? undefined : 'danger'}>
{formatAnswer(selectedAnswer)}
</Text>
</div> </div>
<div style={{ marginBottom: 8 }}> }
<Text strong style={{ color: '#52c41a' }}> description={
<div>
</Text> <div style={{ marginBottom: 8 }}>
<Text strong style={{ color: '#52c41a' }}> <Text type="secondary"></Text>
{formatAnswer( <Text strong={answerResult.correct} type={answerResult.correct ? undefined : 'danger'}>
answerResult.correct_answer || (answerResult.correct ? selectedAnswer : '暂无') {formatAnswer(answerResult.user_answer || selectedAnswer)}
)} </Text>
</Text> </div>
<div style={{ marginBottom: 8 }}>
<Text strong style={{ color: '#52c41a' }}>
</Text>
<Text strong style={{ color: '#52c41a' }}>
{formatAnswer(
answerResult.correct_answer || (answerResult.correct ? selectedAnswer : '暂无')
)}
</Text>
</div>
{answerResult.explanation && (
<div>
<Text type="secondary"></Text>
<div style={{ marginTop: 4 }}>{answerResult.explanation}</div>
</div>
)}
</div> </div>
{answerResult.explanation && ( }
<div> />
<Text type="secondary"></Text>
<div style={{ marginTop: 4 }}>{answerResult.explanation}</div> {/* 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> </div>
)} )}
</div>
} {/* 改进建议 */}
style={{ marginTop: 20 }} {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>
) )
} }

View File

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

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

View 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

View File

@ -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评分结果仅简答题
} }
// 统计数据 // 统计数据