diff --git a/CLAUDE.md b/CLAUDE.md index 8c419dc..230607b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,10 +55,17 @@ func MiddlewareName() gin.HandlerFunc { ### 模块结构 - **main.go** - 应用程序入口点,服务器配置和路由设置 - **internal/handlers/** - HTTP 请求处理器,全部使用 `*gin.Context` 并返回 JSON 响应 + - [user.go](internal/handlers/user.go) - 用户登录注册相关 + - [practice_handler.go](internal/handlers/practice_handler.go) - 练习题相关 + - [handlers.go](internal/handlers/handlers.go) - 通用处理器(健康检查等) - **internal/middleware/** - Gin 中间件链(当前:自定义日志记录器、CORS) -- **internal/models/** - 数据模型(用户模型、问题模型等) +- **internal/models/** - 数据模型 + - [user.go](internal/models/user.go) - 用户模型 + - [practice_question.go](internal/models/practice_question.go) - 练习题模型 - **internal/database/** - 数据库连接和初始化 - **pkg/config/** - 配置管理(数据库配置等) +- **scripts/** - 工具脚本 + - [import_questions.go](scripts/import_questions.go) - 题目数据导入脚本 ## 常用命令 @@ -75,6 +82,9 @@ go fmt ./... # 检查代码常见问题 go vet ./... + +# 导入练习题数据(首次运行需要) +go run scripts/import_questions.go ``` ### 构建 @@ -142,6 +152,34 @@ go test -v ./... 3. 在 [internal/database/database.go](internal/database/database.go) 的 `InitDB()` 中添加 `AutoMigrate` 4. 在处理器中使用 `database.GetDB()` 进行数据库操作 +### 导入数据到数据库 +**示例**: 练习题数据导入 + +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") + + // 解析JSON + var items []YourStruct + json.Unmarshal(data, &items) + + // 插入数据库 + db := database.GetDB() + for _, item := range items { + db.Create(&item) + } + ``` +5. **运行导入脚本**: `go run scripts/import_questions.go` + +**注意事项**: +- JSON中复杂数据(如数组、对象)需要序列化为字符串存储 +- 使用唯一索引防止重复导入 +- 大批量导入建议使用事务提高性能 + ## 前端开发规范 ### 包管理和开发 diff --git a/README.md b/README.md index d8bffb1..379528d 100644 --- a/README.md +++ b/README.md @@ -62,13 +62,12 @@ yarn dev - `POST /api/login` - 用户登录 - `POST /api/register` - 用户注册 -#### 题目相关 -- `GET /api/questions` - 获取题目列表 -- `GET /api/questions/random` - 获取随机题目 -- `GET /api/questions/:id` - 获取指定题目 -- `POST /api/submit` - 提交答案 -- `GET /api/statistics` - 获取统计数据 -- `POST /api/reset` - 重置进度 +#### 练习题相关 +- `GET /api/practice/questions` - 获取练习题目列表 (支持分页和类型过滤) +- `GET /api/practice/questions/random` - 获取随机练习题目 +- `GET /api/practice/questions/:id` - 获取指定练习题目 +- `POST /api/practice/submit` - 提交练习答案 +- `GET /api/practice/types` - 获取题型列表 #### 其他 - `GET /api/health` - 健康检查端点 @@ -133,6 +132,34 @@ go build -o bin/server.exe main.go - `updated_at` - 更新时间 - `deleted_at` - 软删除时间 +**practice_questions 表**: +- `id` - 主键 +- `question_id` - 题目ID(唯一索引,来自JSON数据) +- `type` - 题型(fill-in-blank/true-false/multiple-choice/multiple-selection/short-answer) +- `type_name` - 题型名称(中文) +- `question` - 题目内容 +- `answer_data` - 答案数据(JSON格式) +- `options_data` - 选项数据(JSON格式,选择题使用) +- `created_at` - 创建时间 +- `updated_at` - 更新时间 +- `deleted_at` - 软删除时间 + +### 数据导入 + +首次运行项目需要导入练习题数据: + +```bash +# 确保 practice_question_pool.json 文件在项目根目录 +go run scripts/import_questions.go +``` + +导入脚本会读取 [practice_question_pool.json](practice_question_pool.json) 文件并导入到数据库,共包含236道练习题,涵盖: +- 填空题 (80道) +- 判断题 (80道) +- 单选题 (40道) +- 多选题 (30道) +- 简答题 (6道) + ## 前端开发 前端项目位于 `web/` 目录,使用 **yarn** 作为包管理工具。 @@ -173,8 +200,9 @@ yarn build - 健康检查端点 - 用户登录和注册系统(基于PostgreSQL数据库) - 密码bcrypt加密存储 -- 题目练习功能 -- 答题统计功能 +- 练习题管理系统(236道练习题,5种题型) +- 支持分页查询和题型筛选 +- 随机题目推送功能 ### 前端特性 - React + TypeScript + Vite 技术栈 diff --git a/internal/database/database.go b/internal/database/database.go index 894d9ca..c841f72 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -29,7 +29,10 @@ func InitDB() error { log.Println("Database connected successfully") // 自动迁移数据库表结构 - err = DB.AutoMigrate(&models.User{}) + err = DB.AutoMigrate( + &models.User{}, + &models.PracticeQuestion{}, + ) if err != nil { return fmt.Errorf("failed to migrate database: %w", err) } diff --git a/internal/handlers/practice_handler.go b/internal/handlers/practice_handler.go new file mode 100644 index 0000000..dda3518 --- /dev/null +++ b/internal/handlers/practice_handler.go @@ -0,0 +1,364 @@ +package handlers + +import ( + "ankao/internal/database" + "ankao/internal/models" + "encoding/json" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" +) + +// GetPracticeQuestions 获取练习题目列表 +func GetPracticeQuestions(c *gin.Context) { + typeParam := c.Query("type") + category := c.Query("category") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "100")) + + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 100 + } + + db := database.GetDB() + var questions []models.PracticeQuestion + var total int64 + + query := db.Model(&models.PracticeQuestion{}) + + // 根据题型过滤 - 将前端类型映射到后端类型 + if typeParam != "" { + backendType := mapFrontendToBackendType(typeParam) + query = query.Where("type = ?", backendType) + } + + // 根据分类过滤 + if category != "" { + query = query.Where("type_name = ?", category) + } + + // 获取总数 + query.Count(&total) + + // 分页查询 + offset := (page - 1) * pageSize + err := query.Offset(offset).Limit(pageSize).Find(&questions).Error + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "查询题目失败", + }) + return + } + + // 转换为DTO + dtos := make([]models.PracticeQuestionDTO, len(questions)) + for i, q := range questions { + dto := convertToDTO(q) + dtos[i] = dto + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": dtos, + "total": total, + }) +} + +// GetPracticeQuestionByID 获取单个练习题目 +func GetPracticeQuestionByID(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "无效的题目ID", + }) + return + } + + db := database.GetDB() + var question models.PracticeQuestion + + if err := db.First(&question, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{ + "success": false, + "message": "题目不存在", + }) + return + } + + // 转换为DTO + dto := convertToDTO(question) + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": dto, + }) +} + +// GetRandomPracticeQuestion 获取随机练习题目 +func GetRandomPracticeQuestion(c *gin.Context) { + typeParam := c.Query("type") + + db := database.GetDB() + var question models.PracticeQuestion + + query := db.Model(&models.PracticeQuestion{}) + if typeParam != "" { + backendType := mapFrontendToBackendType(typeParam) + query = query.Where("type = ?", backendType) + } + + // 使用PostgreSQL的随机排序 + if err := query.Order("RANDOM()").First(&question).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{ + "success": false, + "message": "暂无题目", + }) + return + } + + // 转换为DTO + dto := convertToDTO(question) + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": dto, + }) +} + +// SubmitPracticeAnswer 提交练习答案 +func SubmitPracticeAnswer(c *gin.Context) { + var submit models.PracticeAnswerSubmit + if err := c.ShouldBindJSON(&submit); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "请求参数错误", + }) + return + } + + db := database.GetDB() + var question models.PracticeQuestion + + if err := db.First(&question, submit.QuestionID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{ + "success": false, + "message": "题目不存在", + }) + return + } + + // 解析正确答案 + var correctAnswer interface{} + if err := json.Unmarshal([]byte(question.AnswerData), &correctAnswer); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "答案数据错误", + }) + return + } + + // 验证答案 + correct := checkPracticeAnswer(question.Type, submit.Answer, correctAnswer) + + result := models.PracticeAnswerResult{ + Correct: correct, + UserAnswer: submit.Answer, + } + + // 如果答案错误,返回正确答案 + if !correct { + result.CorrectAnswer = correctAnswer + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": result, + }) +} + +// GetPracticeQuestionTypes 获取题型列表 +func GetPracticeQuestionTypes(c *gin.Context) { + types := []gin.H{ + { + "type": models.FillInBlank, + "type_name": "填空题", + }, + { + "type": models.TrueFalseType, + "type_name": "判断题", + }, + { + "type": models.MultipleChoiceQ, + "type_name": "选择题", + }, + { + "type": models.MultipleSelection, + "type_name": "多选题", + }, + { + "type": models.ShortAnswer, + "type_name": "简答题", + }, + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": types, + }) +} + +// checkPracticeAnswer 检查练习答案是否正确 +func checkPracticeAnswer(questionType models.PracticeQuestionType, userAnswer, correctAnswer interface{}) bool { + switch questionType { + case models.TrueFalseType: + // 判断题: boolean 比较 + userBool, ok1 := userAnswer.(bool) + correctBool, ok2 := correctAnswer.(bool) + return ok1 && ok2 && userBool == correctBool + + case models.MultipleChoiceQ: + // 单选题: 字符串比较 + userStr, ok1 := userAnswer.(string) + correctStr, ok2 := correctAnswer.(string) + return ok1 && ok2 && userStr == correctStr + + case models.MultipleSelection: + // 多选题: 数组比较 + userArr, ok1 := toStringArray(userAnswer) + correctArr, ok2 := toStringArray(correctAnswer) + if !ok1 || !ok2 || len(userArr) != len(correctArr) { + return false + } + + // 转换为map进行比较 + userMap := make(map[string]bool) + for _, v := range userArr { + userMap[v] = true + } + for _, v := range correctArr { + if !userMap[v] { + return false + } + } + return true + + case models.FillInBlank: + // 填空题: 数组比较 + userArr, ok1 := toStringArray(userAnswer) + correctArr, ok2 := toStringArray(correctAnswer) + if !ok1 || !ok2 || len(userArr) != len(correctArr) { + return false + } + + // 逐个比较填空答案 + for i := range correctArr { + if userArr[i] != correctArr[i] { + return false + } + } + return true + + case models.ShortAnswer: + // 简答题: 字符串比较(简单实现,实际可能需要更复杂的判断) + userStr, ok1 := userAnswer.(string) + correctStr, ok2 := correctAnswer.(string) + return ok1 && ok2 && userStr == correctStr + } + + return false +} + +// toStringArray 将interface{}转换为字符串数组 +func toStringArray(v interface{}) ([]string, bool) { + switch arr := v.(type) { + case []string: + return arr, true + case []interface{}: + result := make([]string, len(arr)) + for i, item := range arr { + if str, ok := item.(string); ok { + result[i] = str + } else { + return nil, false + } + } + return result, true + default: + return nil, false + } +} + +// mapFrontendToBackendType 将前端类型映射到后端类型 +func mapFrontendToBackendType(frontendType string) models.PracticeQuestionType { + typeMap := map[string]models.PracticeQuestionType{ + "single": models.MultipleChoiceQ, // 单选 + "multiple": models.MultipleSelection, // 多选 + "judge": models.TrueFalseType, // 判断 + "fill": models.FillInBlank, // 填空 + "short": models.ShortAnswer, // 简答 + } + + if backendType, ok := typeMap[frontendType]; ok { + return backendType + } + return models.MultipleChoiceQ // 默认返回单选 +} + +// mapBackendToFrontendType 将后端类型映射到前端类型 +func mapBackendToFrontendType(backendType models.PracticeQuestionType) string { + typeMap := map[models.PracticeQuestionType]string{ + models.MultipleChoiceQ: "single", // 单选 + models.MultipleSelection: "multiple", // 多选 + models.TrueFalseType: "judge", // 判断 + models.FillInBlank: "fill", // 填空 + models.ShortAnswer: "short", // 简答 + } + + if frontendType, ok := typeMap[backendType]; ok { + return frontendType + } + return "single" // 默认返回单选 +} + +// convertToDTO 将数据库模型转换为前端DTO +func convertToDTO(question models.PracticeQuestion) models.PracticeQuestionDTO { + dto := models.PracticeQuestionDTO{ + ID: question.ID, + Type: mapBackendToFrontendType(question.Type), + Content: question.Question, + Category: question.TypeName, + Options: []models.Option{}, + } + + // 判断题自动生成选项 + if question.Type == models.TrueFalseType { + dto.Options = []models.Option{ + {Key: "A", Value: "对"}, + {Key: "B", Value: "错"}, + } + return dto + } + + // 解析选项数据(如果有) + if question.OptionsData != "" { + var optionsMap map[string]string + if err := json.Unmarshal([]byte(question.OptionsData), &optionsMap); err == nil { + // 将map转换为Option数组 + for key, value := range optionsMap { + dto.Options = append(dto.Options, models.Option{ + Key: key, + Value: value, + }) + } + } + } + + return dto +} diff --git a/internal/handlers/question_handler.go b/internal/handlers/question_handler.go deleted file mode 100644 index b81a819..0000000 --- a/internal/handlers/question_handler.go +++ /dev/null @@ -1,267 +0,0 @@ -package handlers - -import ( - "ankao/internal/models" - "math/rand" - "net/http" - "strconv" - "sync" - - "github.com/gin-gonic/gin" -) - -// 用于存储答题记录的简单内存存储 -var ( - userAnswers = make(map[int]interface{}) - mu sync.RWMutex -) - -// GetQuestions 获取题目列表 -func GetQuestions(c *gin.Context) { - questionType := c.Query("type") - category := c.Query("category") - - questions := GetTestQuestions() - - // 过滤题目 - var filtered []models.Question - for _, q := range questions { - if questionType != "" && string(q.Type) != questionType { - continue - } - if category != "" && q.Category != category { - continue - } - filtered = append(filtered, q) - } - - c.JSON(http.StatusOK, gin.H{ - "success": true, - "data": filtered, - "total": len(filtered), - }) -} - -// GetQuestionByID 获取单个题目 -func GetQuestionByID(c *gin.Context) { - idStr := c.Param("id") - id, err := strconv.Atoi(idStr) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{ - "success": false, - "message": "无效的题目ID", - }) - return - } - - questions := GetTestQuestions() - for _, q := range questions { - if q.ID == id { - c.JSON(http.StatusOK, gin.H{ - "success": true, - "data": q, - }) - return - } - } - - c.JSON(http.StatusNotFound, gin.H{ - "success": false, - "message": "题目不存在", - }) -} - -// GetRandomQuestion 获取随机题目 -func GetRandomQuestion(c *gin.Context) { - questions := GetTestQuestions() - if len(questions) == 0 { - c.JSON(http.StatusNotFound, gin.H{ - "success": false, - "message": "暂无题目", - }) - return - } - - randomQuestion := questions[rand.Intn(len(questions))] - c.JSON(http.StatusOK, gin.H{ - "success": true, - "data": randomQuestion, - }) -} - -// SubmitAnswer 提交答案 -func SubmitAnswer(c *gin.Context) { - var submit models.SubmitAnswer - if err := c.ShouldBindJSON(&submit); err != nil { - c.JSON(http.StatusBadRequest, gin.H{ - "success": false, - "message": "请求参数错误", - }) - return - } - - // 查找题目 - questions := GetTestQuestions() - var targetQuestion *models.Question - for i := range questions { - if questions[i].ID == submit.QuestionID { - targetQuestion = &questions[i] - break - } - } - - if targetQuestion == nil { - c.JSON(http.StatusNotFound, gin.H{ - "success": false, - "message": "题目不存在", - }) - return - } - - // 验证答案 - correct := checkAnswer(targetQuestion, submit.Answer) - - // 保存答题记录 - mu.Lock() - userAnswers[submit.QuestionID] = submit.Answer - mu.Unlock() - - result := models.AnswerResult{ - Correct: correct, - CorrectAnswer: targetQuestion.Answer, - Explanation: getExplanation(targetQuestion.ID), - } - - c.JSON(http.StatusOK, gin.H{ - "success": true, - "data": result, - }) -} - -// GetStatistics 获取统计数据 -func GetStatistics(c *gin.Context) { - mu.RLock() - answeredCount := len(userAnswers) - mu.RUnlock() - - questions := GetTestQuestions() - totalCount := len(questions) - - // 计算正确答案数 - correctCount := 0 - mu.RLock() - for qid, userAns := range userAnswers { - for i := range questions { - if questions[i].ID == qid { - if checkAnswer(&questions[i], userAns) { - correctCount++ - } - break - } - } - } - mu.RUnlock() - - accuracy := 0.0 - if answeredCount > 0 { - accuracy = float64(correctCount) / float64(answeredCount) * 100 - } - - stats := models.Statistics{ - TotalQuestions: totalCount, - AnsweredQuestions: answeredCount, - CorrectAnswers: correctCount, - Accuracy: accuracy, - } - - c.JSON(http.StatusOK, gin.H{ - "success": true, - "data": stats, - }) -} - -// ResetProgress 重置答题进度 -func ResetProgress(c *gin.Context) { - mu.Lock() - userAnswers = make(map[int]interface{}) - mu.Unlock() - - c.JSON(http.StatusOK, gin.H{ - "success": true, - "message": "答题进度已重置", - }) -} - -// checkAnswer 检查答案是否正确 -func checkAnswer(question *models.Question, userAnswer interface{}) bool { - switch question.Type { - case models.SingleChoice, models.TrueFalse: - // 单选和判断题:字符串比较 - return userAnswer == question.Answer - - case models.MultipleChoice: - // 多选题:数组比较 - userArr, ok1 := userAnswer.([]interface{}) - correctArr, ok2 := question.Answer.([]string) - if !ok1 || !ok2 { - return false - } - if len(userArr) != len(correctArr) { - return false - } - // 转换为map进行比较 - userMap := make(map[string]bool) - for _, v := range userArr { - if str, ok := v.(string); ok { - userMap[str] = true - } - } - for _, v := range correctArr { - if !userMap[v] { - return false - } - } - return true - - case models.FillBlank: - // 填空题:字符串比较(忽略大小写和空格) - userStr, ok := userAnswer.(string) - if !ok { - return false - } - correctStr, ok := question.Answer.(string) - if !ok { - return false - } - return userStr == correctStr - } - - return false -} - -// getExplanation 获取答案解析 -func getExplanation(questionID int) string { - explanations := map[int]string{ - 1: "根据国家保密局规定,涉密信息系统集成资质分为甲级、乙级、丙级三个等级。", - 2: "涉密信息系统集成资质由国家保密局认证管理,负责资质的审批和监督。", - 3: "涉密信息系统集成资质证书有效期为3年,有效期满需要重新申请认证。", - 4: "涉密人员管理包括保密教育培训、保密协议签订、离岗离职审查和保密审查等内容。", - 5: "涉密信息系统集成单位应当具有独立法人资格、固定办公场所、保密管理制度和保密管理人员。", - 6: "涉密载体管理包括登记标识、使用保管、复制传递、维修销毁等全生命周期管理。", - 7: "涉密信息系统集成资质单位只能承担本单位资质等级及以下的涉密信息系统集成业务,不得超越资质等级承揽项目。", - 8: "保密要害部门部位人员关系到国家秘密安全,必须经过严格的保密审查才能上岗。", - 9: "涉密人员离岗离职后需要经过脱密期管理,期间不得擅自出境,防止泄露国家秘密。", - 10: "涉密信息系统集成资质等级包括甲级、乙级、丙级三个等级,甲级最高,丙级最低。", - 11: "国家秘密密级分为绝密、机密、秘密三级,绝密级最高,秘密级最低。", - 12: "涉密人员上岗前必须经过保密教育培训,提高保密意识,并签订保密承诺书。", - 13: "涉密场所应当采取物理防护、技术防护等措施,防止国家秘密泄露。", - 14: "涉密信息系统应当按照国家保密标准要求进行分级保护,确保信息安全。", - 15: "根据《保密法》规定,涉密人员脱密期最长不超过3年。", - 16: "涉密计算机及移动存储介质应当按照所存储信息的最高密级粘贴密级标识。", - 17: "甲级资质单位可以承担绝密级、机密级和秘密级的涉密信息系统集成业务。", - 18: "涉密载体的复制应当经过审批并进行详细登记,防止失控泄密。", - 19: "涉密会议场所应当采取信号屏蔽、安全检查等保密防护措施。", - 20: "涉密业务不得分包给非资质单位,防止国家秘密泄露。", - } - return explanations[questionID] -} diff --git a/internal/handlers/test_data.go b/internal/handlers/test_data.go deleted file mode 100644 index 7128193..0000000 --- a/internal/handlers/test_data.go +++ /dev/null @@ -1,242 +0,0 @@ -package handlers - -import "ankao/internal/models" - -// GetTestQuestions 获取测试题目数据 - 涉密信息系统集成资质保密知识 -func GetTestQuestions() []models.Question { - return []models.Question{ - // 单选题 - 涉密信息系统集成资质相关 - { - ID: 1, - Type: models.SingleChoice, - Content: "一切国家机关、武装力量、政党、社会团体、()都有保守国家秘密的义务", - Options: []models.Option{ - {Key: "A", Value: "国家公务员"}, - {Key: "B", Value: "共产党员"}, - {Key: "C", Value: "企业事业单位和公民"}, - }, - Answer: "C", - Category: "资质等级", - }, - { - ID: 2, - Type: models.SingleChoice, - Content: "涉密信息系统集成资质由哪个部门认证管理?", - Options: []models.Option{ - {Key: "A", Value: "工信部"}, - {Key: "B", Value: "国家保密局"}, - {Key: "C", Value: "公安部"}, - {Key: "D", Value: "网信办"}, - }, - Answer: "B", - Category: "资质管理", - }, - { - ID: 3, - Type: models.SingleChoice, - Content: "涉密信息系统集成资质有效期为几年?", - Options: []models.Option{ - {Key: "A", Value: "1年"}, - {Key: "B", Value: "2年"}, - {Key: "C", Value: "3年"}, - {Key: "D", Value: "5年"}, - }, - Answer: "C", - Category: "资质管理", - }, - - // 多选题 - 涉密保密管理相关 - { - ID: 4, - Type: models.MultipleChoice, - Content: "以下哪些属于涉密人员管理的内容?", - Options: []models.Option{ - {Key: "A", Value: "保密教育培训"}, - {Key: "B", Value: "保密协议签订"}, - {Key: "C", Value: "离岗离职审查"}, - {Key: "D", Value: "保密审查"}, - }, - Answer: []string{"A", "B", "C", "D"}, - Category: "保密管理", - }, - { - ID: 5, - Type: models.MultipleChoice, - Content: "涉密信息系统集成单位应具备哪些基本条件?", - Options: []models.Option{ - {Key: "A", Value: "具有独立法人资格"}, - {Key: "B", Value: "具有固定的办公场所"}, - {Key: "C", Value: "建立保密管理制度"}, - {Key: "D", Value: "配备保密管理人员"}, - }, - Answer: []string{"A", "B", "C", "D"}, - Category: "资质条件", - }, - { - ID: 6, - Type: models.MultipleChoice, - Content: "涉密载体管理包括哪些方面?", - Options: []models.Option{ - {Key: "A", Value: "登记标识"}, - {Key: "B", Value: "使用保管"}, - {Key: "C", Value: "复制传递"}, - {Key: "D", Value: "维修销毁"}, - }, - Answer: []string{"A", "B", "C", "D"}, - Category: "保密管理", - }, - - // 判断题 - 涉密保密知识 - { - ID: 7, - Type: models.TrueFalse, - Content: "涉密信息系统集成资质单位可以超越资质等级承揽项目", - Options: []models.Option{ - {Key: "A", Value: "正确"}, - {Key: "B", Value: "错误"}, - }, - Answer: "B", - Category: "资质管理", - }, - { - ID: 8, - Type: models.TrueFalse, - Content: "保密要害部门部位人员应当进行保密审查", - Options: []models.Option{ - {Key: "A", Value: "正确"}, - {Key: "B", Value: "错误"}, - }, - Answer: "A", - Category: "保密管理", - }, - { - ID: 9, - Type: models.TrueFalse, - Content: "涉密人员离岗离职实行脱密期管理,脱密期内不得擅自出境", - Options: []models.Option{ - {Key: "A", Value: "正确"}, - {Key: "B", Value: "错误"}, - }, - Answer: "A", - Category: "保密管理", - }, - - // 填空题 - 涉密保密知识 - { - ID: 10, - Type: models.FillBlank, - Content: "涉密信息系统集成资质分为甲级、乙级、_____ 三个等级。", - Options: nil, - Answer: "丙级", - Category: "资质等级", - }, - { - ID: 11, - Type: models.FillBlank, - Content: "国家秘密的密级分为绝密、机密、_____ 三级。", - Options: nil, - Answer: "秘密", - Category: "保密知识", - }, - { - ID: 12, - Type: models.FillBlank, - Content: "涉密人员上岗前应当经过_____ 并签订保密承诺书。", - Options: nil, - Answer: "保密教育培训", - Category: "保密管理", - }, - { - ID: 13, - Type: models.FillBlank, - Content: "涉密场所应当采取_____ 措施,防止信息泄露。", - Options: nil, - Answer: "防护", - Category: "保密管理", - }, - { - ID: 14, - Type: models.FillBlank, - Content: "涉密信息系统应当按照_____ 要求分级保护。", - Options: nil, - Answer: "国家保密标准", - Category: "保密知识", - }, - - // 更多单选题 - 涉密保密知识 - { - ID: 15, - Type: models.SingleChoice, - Content: "涉密人员脱密期最长不超过多少年?", - Options: []models.Option{ - {Key: "A", Value: "1年"}, - {Key: "B", Value: "2年"}, - {Key: "C", Value: "3年"}, - {Key: "D", Value: "5年"}, - }, - Answer: "C", - Category: "保密管理", - }, - { - ID: 16, - Type: models.SingleChoice, - Content: "涉密计算机及移动存储介质应当粘贴什么标识?", - Options: []models.Option{ - {Key: "A", Value: "密级标识"}, - {Key: "B", Value: "警示标识"}, - {Key: "C", Value: "保密标识"}, - {Key: "D", Value: "专用标识"}, - }, - Answer: "A", - Category: "保密管理", - }, - { - ID: 17, - Type: models.SingleChoice, - Content: "甲级资质单位可以承担什么密级的涉密信息系统集成业务?", - Options: []models.Option{ - {Key: "A", Value: "仅机密级"}, - {Key: "B", Value: "秘密级和机密级"}, - {Key: "C", Value: "绝密级、机密级和秘密级"}, - {Key: "D", Value: "仅秘密级"}, - }, - Answer: "C", - Category: "资质等级", - }, - - // 更多判断题 - 涉密保密知识 - { - ID: 18, - Type: models.TrueFalse, - Content: "涉密载体的复制应当经过审批并进行登记", - Options: []models.Option{ - {Key: "A", Value: "正确"}, - {Key: "B", Value: "错误"}, - }, - Answer: "A", - Category: "保密管理", - }, - { - ID: 19, - Type: models.TrueFalse, - Content: "涉密会议场所应当采取必要的保密防护措施", - Options: []models.Option{ - {Key: "A", Value: "正确"}, - {Key: "B", Value: "错误"}, - }, - Answer: "A", - Category: "保密管理", - }, - { - ID: 20, - Type: models.TrueFalse, - Content: "涉密信息系统集成资质单位可以将涉密业务分包给非资质单位", - Options: []models.Option{ - {Key: "A", Value: "正确"}, - {Key: "B", Value: "错误"}, - }, - Answer: "B", - Category: "资质管理", - }, - } -} diff --git a/internal/models/practice_question.go b/internal/models/practice_question.go new file mode 100644 index 0000000..8752d05 --- /dev/null +++ b/internal/models/practice_question.go @@ -0,0 +1,52 @@ +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 { + 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 指定表名 +func (PracticeQuestion) TableName() string { + return "practice_questions" +} + +// PracticeQuestionDTO 用于前端返回的数据传输对象 +type PracticeQuestionDTO struct { + 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 uint `json:"question_id" binding:"required"` // 数据库ID + Answer interface{} `json:"answer" binding:"required"` // 用户答案 +} + +// PracticeAnswerResult 练习题答案结果 +type PracticeAnswerResult struct { + Correct bool `json:"correct"` // 是否正确 + UserAnswer interface{} `json:"user_answer"` // 用户答案 + CorrectAnswer interface{} `json:"correct_answer,omitempty"` // 正确答案(仅在错误时返回) +} diff --git a/main.go b/main.go index db0e776..e00a7cf 100644 --- a/main.go +++ b/main.go @@ -37,13 +37,12 @@ func main() { api.POST("/login", handlers.Login) // 用户登录 api.POST("/register", handlers.Register) // 用户注册 - // 题目相关API - api.GET("/questions", handlers.GetQuestions) // 获取题目列表 - api.GET("/questions/random", handlers.GetRandomQuestion) // 获取随机题目 - api.GET("/questions/:id", handlers.GetQuestionByID) // 获取指定题目 - api.POST("/submit", handlers.SubmitAnswer) // 提交答案 - api.GET("/statistics", handlers.GetStatistics) // 获取统计数据 - api.POST("/reset", handlers.ResetProgress) // 重置进度 + // 练习题相关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) // 获取题型列表 } // 启动服务器 diff --git a/practice_question_pool.json b/practice_question_pool.json index 552396f..3705cb5 100644 --- a/practice_question_pool.json +++ b/practice_question_pool.json @@ -3,397 +3,397 @@ "list": [ { "id": "1", - "question": "1.《中华人民共和国宪法》第五十三条规定,中华人民共和国****必须遵守宪法和法律,****,爱护公共财产,遵守劳动纪律,遵守公共秩序,尊重社会公德。", + "question": "《中华人民共和国宪法》第五十三条规定,中华人民共和国****必须遵守宪法和法律,****,爱护公共财产,遵守劳动纪律,遵守公共秩序,尊重社会公德。", "answers": ["公民", "保守国家秘密"] }, { "id": "2", - "question": "2.《中华人民共和国保守国家秘密法》由中华人民共和国第11届全国人民代表大会常务委员会第十四次会议于2010年4月29日修订通过,自****起施行。", + "question": "《中华人民共和国保守国家秘密法》由中华人民共和国第11届全国人民代表大会常务委员会第十四次会议于2010年4月29日修订通过,自****起施行。", "answers": ["2010年10月1日"] }, { "id": "3", - "question": "3.国家秘密是指关系国家的安全和利益,依照****确定,在一定时间内****的人员知悉的事项。", + "question": "国家秘密是指关系国家的安全和利益,依照****确定,在一定时间内****的人员知悉的事项。", "answers": ["法定程序", "只限一定范围"] }, { "id": "4", - "question": "4.保守国家秘密的工作,实行****、突出重点、****的方针,既确保国家秘密安全,又****。", + "question": "保守国家秘密的工作,实行****、突出重点、****的方针,既确保国家秘密安全,又****。", "answers": ["积极防范", "依法管理", "便利信息资源合理利用"] }, { "id": "5", - "question": "5.机关、单位应当实行保密工作****,健全保密管理制度,完善保密防护措施,开展保密宣传教育,加强****。", + "question": "机关、单位应当实行保密工作****,健全保密管理制度,完善保密防护措施,开展保密宣传教育,加强****。", "answers": ["责任制", "保密检查"] }, { "id": "6", - "question": "6.国家秘密的密级分为****、****、****三级。", + "question": "国家秘密的密级分为****、****、****三级。", "answers": ["绝密", "机密", "秘密"] }, { "id": "7", - "question": "7.绝密级国家秘密是****的国家秘密,泄露会使国家安全和利益遭受****。", + "question": "绝密级国家秘密是****的国家秘密,泄露会使国家安全和利益遭受****。", "answers": ["最重要", "特别严重的损害"] }, { "id": "8", - "question": "8.机密级国家秘密是****的国家秘密,泄露会使国家安全和利益遭受****。", + "question": "机密级国家秘密是****的国家秘密,泄露会使国家安全和利益遭受****。", "answers": ["重要", "严重的损害"] }, { "id": "9", - "question": "9.秘密级国家秘密是****的国家秘密,泄露会使国家安全和利益遭受****。", + "question": "秘密级国家秘密是****的国家秘密,泄露会使国家安全和利益遭受****。", "answers": ["一般", "损害"] }, { "id": "10", - "question": "10.机关、单位对所产生的国家秘密事项,应当按照国家秘密及其密级的具体范围的规定确定密级,同时确定****和****。", + "question": "机关、单位对所产生的国家秘密事项,应当按照国家秘密及其密级的具体范围的规定确定密级,同时确定****和****。", "answers": ["保密期限", "知悉范围"] }, { "id": "11", - "question": "11.国家秘密的保密期限,除有特殊规定外,绝密级事项不超过****年,机密级事项不超过****年,秘密级事项不超过****年。", + "question": "国家秘密的保密期限,除有特殊规定外,绝密级事项不超过****年,机密级事项不超过****年,秘密级事项不超过****年。", "answers": ["30", "20", "10"] }, { "id": "12", - "question": "12.国家秘密的知悉范围,应当根据工作需要****。", + "question": "国家秘密的知悉范围,应当根据工作需要****。", "answers": ["限定在最小范围"] }, { "id": "13", - "question": "13.国家秘密的知悉范围能够具体限定到具体人员的,****;不能限定到具体人员的,限定到****,由其限定到具体的人员。", + "question": "国家秘密的知悉范围能够具体限定到具体人员的,****;不能限定到具体人员的,限定到****,由其限定到具体的人员。", "answers": ["限定到具体人员", "机关、单位"] }, { "id": "14", - "question": "14.国家秘密的知悉范围以外的人员,因工作需要知悉国家秘密的,应当经过机关、单位负责人****。", + "question": "国家秘密的知悉范围以外的人员,因工作需要知悉国家秘密的,应当经过机关、单位负责人****。", "answers": ["批准"] }, { "id": "15", - "question": "15.机关、单位对承载国家秘密的纸介质、光介质、电磁介质等载体以及属于国家秘密的设备、产品,应当做出****标志。", + "question": "机关、单位对承载国家秘密的纸介质、光介质、电磁介质等载体以及属于国家秘密的设备、产品,应当做出****标志。", "answers": ["国家秘密"] }, { "id": "16", - "question": "16.国家秘密的密级、保密期限和知悉范围的变更,应由****决定,也可由其****决定。", + "question": "国家秘密的密级、保密期限和知悉范围的变更,应由****决定,也可由其****决定。", "answers": ["原定密机关、单位", "上级机关"] }, { "id": "17", - "question": "17.禁止在互联网及其他公共信息网络或者在未采取保密措施的****中传递国家秘密。", + "question": "禁止在互联网及其他公共信息网络或者在未采取保密措施的****中传递国家秘密。", "answers": ["有线和无线通信"] }, { "id": "18", - "question": "18.互联网、移动通信网等公共信息网络及其他传媒的信息****、****,应当遵守有关保密规定。", + "question": "互联网、移动通信网等公共信息网络及其他传媒的信息****、****,应当遵守有关保密规定。", "answers": ["编辑", "发布"] }, { "id": "19", - "question": "19.举办会议或者其他活动涉及国家秘密的,****应当采取保密措施,并对参加人员进行****,提出具体保密要求。", + "question": "举办会议或者其他活动涉及国家秘密的,****应当采取保密措施,并对参加人员进行****,提出具体保密要求。", "answers": ["主办单位", "保密教育"] }, { "id": "20", - "question": "20.在涉密岗位工作的人员(简称涉密人员),按照涉密程度分为:****涉密人员、****涉密人员和****涉密人员,实行分类管理。**", + "question": "在涉密岗位工作的人员(简称涉密人员),按照涉密程度分为:****涉密人员、****涉密人员和****涉密人员,实行分类管理。**", "answers": ["核心", "重要", "一般"] }, { "id": "21", - "question": "21.涉密人员脱离涉密岗位必须实行脱密期管理,其中核心涉密人员脱密期为****年,重要涉密人员为****年,一般涉密人员为****年。", + "question": "涉密人员脱离涉密岗位必须实行脱密期管理,其中核心涉密人员脱密期为****年,重要涉密人员为****年,一般涉密人员为****年。", "answers": ["2-3", "1-2", "6个月至1"] }, { "id": "22", - "question": "22.涉密人员应当具有良好的****,具有胜任涉密岗位所要求的****。", + "question": "涉密人员应当具有良好的****,具有胜任涉密岗位所要求的****。", "answers": ["政治素质和品行", "工作能力"] }, { "id": "23", - "question": "23.机关、单位应当按照保密法的规定,严格限定国家秘密的****,对知悉机密级以上国家秘密的人员,应当作出****。", + "question": "机关、单位应当按照保密法的规定,严格限定国家秘密的****,对知悉机密级以上国家秘密的人员,应当作出****。", "answers": ["知悉范围", "书面记录"] }, { "id": "24", - "question": "24.国家秘密载体以及属于国家秘密的设备、产品的明显部位应当标注****。", + "question": "国家秘密载体以及属于国家秘密的设备、产品的明显部位应当标注****。", "answers": ["国家秘密标志"] }, { "id": "25", - "question": "25.绝密级国家秘密载体应当在符合国家保密标准的设施、设备中保存,并指定****。", + "question": "绝密级国家秘密载体应当在符合国家保密标准的设施、设备中保存,并指定****。", "answers": ["专人管理"] }, { "id": "26", - "question": "26.收发涉密载体应当履行****、****、****、****等手续。", + "question": "收发涉密载体应当履行****、****、****、****等手续。", "answers": ["清点", "编号", "登记", "签收"] }, { "id": "27", - "question": "27.传递涉密载体应当通过****、****或其它符合保密要求的方式进行。", + "question": "传递涉密载体应当通过****、****或其它符合保密要求的方式进行。", "answers": ["机要交通", "机要通信"] }, { "id": "28", - "question": "28.销毁国家秘密载体应当符合国家保密规定和标准,确保销毁的国家秘密信息****。", + "question": "销毁国家秘密载体应当符合国家保密规定和标准,确保销毁的国家秘密信息****。", "answers": ["无法还原"] }, { "id": "29", - "question": "29.销毁国家秘密载体应当履行****、****、****手续,并送交保密行政管理部门设立的销毁工作机构或者保密行政管理部门指定的单位销毁。", + "question": "销毁国家秘密载体应当履行****、****、****手续,并送交保密行政管理部门设立的销毁工作机构或者保密行政管理部门指定的单位销毁。", "answers": ["清点", "登记", "审批"] }, { "id": "30", - "question": "30.经保密审查合格的企业事业单位违反保密规定的,由保密行政管理部门责令****,逾期不改或整改后仍不符合要求的,****涉密业务;情节严重的,****涉密业务。", + "question": "经保密审查合格的企业事业单位违反保密规定的,由保密行政管理部门责令****,逾期不改或整改后仍不符合要求的,****涉密业务;情节严重的,****涉密业务。", "answers": ["限期整改", "暂停", "停止"] }, { "id": "31", - "question": "31.涉密信息系统未按照规定进行检测评估和审查而投入使用的,由保密行政管理部门责令****,并建议有关机关、单位对****人员和****人员依法给予处分。", + "question": "涉密信息系统未按照规定进行检测评估和审查而投入使用的,由保密行政管理部门责令****,并建议有关机关、单位对****人员和****人员依法给予处分。", "answers": ["改正", "直接负责的主管", "其他直接责任"] }, { "id": "32", - "question": "32.《中华人民共和国刑法》第一百一十一条规定,为境外的机构、组织、人员窃取、刺探、收买、非法提供国家秘密或者情报的,处****以上****以下有期徒刑。", + "question": "《中华人民共和国刑法》第一百一十一条规定,为境外的机构、组织、人员窃取、刺探、收买、非法提供国家秘密或者情报的,处****以上****以下有期徒刑。", "answers": ["五年", "十年"] }, { "id": "33", - "question": "33.涉密信息系统集成(以下简称涉密集成)资质是保密行政管理部门许可企业事业单位从事涉密集成业务的****。", + "question": "涉密信息系统集成(以下简称涉密集成)资质是保密行政管理部门许可企业事业单位从事涉密集成业务的****。", "answers": ["法定资格"] }, { "id": "34", - "question": "34.从事涉密集成业务的生产经营企业事业单位应当依照****,取得涉密信息系统集成资质。", + "question": "从事涉密集成业务的生产经营企业事业单位应当依照****,取得涉密信息系统集成资质。", "answers": ["《涉密信息系统集成资质管理办法》"] }, { "id": "35", - "question": "35.****资质单位可以从事绝密级、机密级、秘密级涉密集成业务;****资质单位可以从事机密级、秘密级涉密集成业务。", + "question": "****资质单位可以从事绝密级、机密级、秘密级涉密集成业务;****资质单位可以从事机密级、秘密级涉密集成业务。", "answers": ["甲级", "乙级"] }, { "id": "36", - "question": "36.涉密集成资质包括****、系统咨询、软件开发、安防监控、屏蔽室建设、****、数据恢复、工程监理。资质单位应当在保密行政管理部门许可的****范围内承接涉密集成业务。", + "question": "涉密集成资质包括****、系统咨询、软件开发、安防监控、屏蔽室建设、****、数据恢复、工程监理。资质单位应当在保密行政管理部门许可的****范围内承接涉密集成业务。", "answers": ["总体集成", "运行维护", "业务种类"] }, { "id": "37", - "question": "37.取得总体集成业务种类许可的,除从事系统集成业务外,还可以从事****、****和****的运行维护业务。", + "question": "取得总体集成业务种类许可的,除从事系统集成业务外,还可以从事****、****和****的运行维护业务。", "answers": ["软件开发", "安防监控", "所承建系统"] }, { "id": "38", - "question": "38.《资质证书》有效期满,需要继续从事涉密集成业务的,应当在有效期届满****前向保密行政管理部门提出延续申请,申请单位未按规定期限提出延续申请的,视为****。", + "question": "《资质证书》有效期满,需要继续从事涉密集成业务的,应当在有效期届满****前向保密行政管理部门提出延续申请,申请单位未按规定期限提出延续申请的,视为****。", "answers": ["3个月", "重新申请"] }, { "id": "39", - "question": "39.资质单位与其他单位合作开展涉密集成业务的,合作单位应当具有****资质且取得委托方****。", + "question": "资质单位与其他单位合作开展涉密集成业务的,合作单位应当具有****资质且取得委托方****。", "answers": ["相应的涉密集成", "书面同意"] }, { "id": "40", - "question": "40.承接涉密集成业务,应当在签订合同后****日内,向****省级保密行政管理部门备案。", + "question": "承接涉密集成业务,应当在签订合同后****日内,向****省级保密行政管理部门备案。", "answers": ["30", "业务所在地"] }, { "id": "41", - "question": "41.资质单位实行年度自检制度,应当于每年****前向作出准予行政许可决定的保密行政管理部门报送上一年度自检报告。", + "question": "资质单位实行年度自检制度,应当于每年****前向作出准予行政许可决定的保密行政管理部门报送上一年度自检报告。", "answers": ["3月31日"] }, { "id": "42", - "question": "42.资质单位应当成立****,为本单位保密工作领导机构。", + "question": "资质单位应当成立****,为本单位保密工作领导机构。", "answers": ["保密工作领导小组"] }, { "id": "43", - "question": "43.资质单位应当设置****,为单位领导班子成员,甲级资质单位应当为****。", + "question": "资质单位应当设置****,为单位领导班子成员,甲级资质单位应当为****。", "answers": ["保密总监", "专职"] }, { "id": "44", - "question": "44.保密管理办公室为资质单位的****部门,负责人由**中层以上管理人员担任。", + "question": "保密管理办公室为资质单位的****部门,负责人由**中层以上管理人员担任。", "answers": ["职能"] }, { "id": "45", - "question": "45.资质单位应当建立规范、操作性强的保密制度,并根据实际情况及时修订完善。保密制度的具体要求应当体现在单位相关****业务工作流程**中。", + "question": "资质单位应当建立规范、操作性强的保密制度,并根据实际情况及时修订完善。保密制度的具体要求应当体现在单位相关****业务工作流程**中。", "answers": ["管理制度和"] }, { "id": "46", - "question": "46.涉密人员应当通过****,并签订**保密承诺书后方能上岗。", + "question": "涉密人员应当通过****,并签订**保密承诺书后方能上岗。", "answers": ["保密教育培训"] }, { "id": "47", - "question": "47.涉密人员因私出国(境)实行****,履行审批程序,必要时应当征求涉密业务****意见。按照出国(境)行前保密教育和****要求管理。", + "question": "涉密人员因私出国(境)实行****,履行审批程序,必要时应当征求涉密业务****意见。按照出国(境)行前保密教育和****要求管理。", "answers": ["统一管理", "委托方", "回访"] }, { "id": "48", - "question": "48.涉密人员擅自出境或逾期不归的,单位应当及时报告****。", + "question": "涉密人员擅自出境或逾期不归的,单位应当及时报告****。", "answers": ["保密行政管理部门"] }, { "id": "49", - "question": "49.涉密人员离岗离职前应当****保管和使用的涉密载体、涉密信息设备等,并按相关保密规定实行****管理。", + "question": "涉密人员离岗离职前应当****保管和使用的涉密载体、涉密信息设备等,并按相关保密规定实行****管理。", "answers": ["清退", "脱密期"] }, { "id": "50", - "question": "50.涉密人员应当主动报告就业变更及****,严格落实****规定。", + "question": "涉密人员应当主动报告就业变更及****,严格落实****规定。", "answers": ["个人重大事项", "从业限制"] }, { "id": "51", - "question": "51.国家秘密载体应当相对集中管理,建立****,定期对涉密载体进行****。", + "question": "国家秘密载体应当相对集中管理,建立****,定期对涉密载体进行****。", "answers": ["台账", "清查"] }, { "id": "52", - "question": "52.持有国家秘密载体或知悉国家秘密应当履行****。未经批准,个人不得****国家秘密载体和涉密文件资料。", + "question": "持有国家秘密载体或知悉国家秘密应当履行****。未经批准,个人不得****国家秘密载体和涉密文件资料。", "answers": ["审批程序", "私自留存"] }, { "id": "53", - "question": "53.复制涉密载体或者摘录、引用、汇编涉密内容应当按照规定审批,按原件****、****和****管理。", + "question": "复制涉密载体或者摘录、引用、汇编涉密内容应当按照规定审批,按原件****、****和****管理。", "answers": ["密级", "保密期限", "知悉范围"] }, { "id": "54", - "question": "54.复制非本单位产生的涉密载体,应当经****机关、单位批准,并加盖****,视同原件管理。", + "question": "复制非本单位产生的涉密载体,应当经****机关、单位批准,并加盖****,视同原件管理。", "answers": ["载体制发", "复制戳记"] }, { "id": "55", - "question": "55.机密、秘密级涉密载体应当存放在****中,绝密级涉密载体应当存放在****中。", + "question": "机密、秘密级涉密载体应当存放在****中,绝密级涉密载体应当存放在****中。", "answers": ["密码文件柜", "密码保险柜"] }, { "id": "57", - "question": "57.涉密信息系统和涉密信息设备应当采取****、****、违规外联监控、****、移动存储介质使用管控等安全保密措施,并及时升级病毒库和恶意代码样本库,定期进行病毒和恶意代码查杀。", + "question": "涉密信息系统和涉密信息设备应当采取****、****、违规外联监控、****、移动存储介质使用管控等安全保密措施,并及时升级病毒库和恶意代码样本库,定期进行病毒和恶意代码查杀。", "answers": ["身份鉴别", "访问控制", "安全审计"] }, { "id": "58", - "question": "58.处理秘密级信息的计算机,口令长度不少于****位,更换周期不得长于****。", + "question": "处理秘密级信息的计算机,口令长度不少于****位,更换周期不得长于****。", "answers": ["8", "一个月"] }, { "id": "59", - "question": "59.处理机密级信息的计算机,口令长度不少于****位,更换周期不得长于****。", + "question": "处理机密级信息的计算机,口令长度不少于****位,更换周期不得长于****。", "answers": ["10", "一周"] }, { "id": "60", - "question": "60.处理绝密级信息的计算机,应采用****等强身份鉴别措施。", + "question": "处理绝密级信息的计算机,应采用****等强身份鉴别措施。", "answers": ["生理特征"] }, { "id": "61", - "question": "61.涉密信息系统和涉密信息设备的信息****应当履行审批程序,****、有效控制,并采取相应审计措施。", + "question": "涉密信息系统和涉密信息设备的信息****应当履行审批程序,****、有效控制,并采取相应审计措施。", "answers": ["导入导出", "相对集中"] }, { "id": "62", - "question": "62.涉密信息设备的维修,应当在****进行,并指定专人****,严禁维修人员读取或复制涉密信息。确需送外维修的,须拆除****。", + "question": "涉密信息设备的维修,应当在****进行,并指定专人****,严禁维修人员读取或复制涉密信息。确需送外维修的,须拆除****。", "answers": ["本单位内部", "全程监督", "涉密信息存储部件"] }, { "id": "63", - "question": "63.涉密信息设备重装操作系统应当履行****程序。报废涉密信息设备,应经过安全技术处理,****涉密信息存储部件。", + "question": "涉密信息设备重装操作系统应当履行****程序。报废涉密信息设备,应经过安全技术处理,****涉密信息存储部件。", "answers": ["审批", "拆除"] }, { "id": "64", - "question": "64.携带涉密计算机和存储介质外出,应当****手续,带出前和带回后应当进行****。", + "question": "携带涉密计算机和存储介质外出,应当****手续,带出前和带回后应当进行****。", "answers": ["审批", "保密检查"] }, { "id": "65", - "question": "65.涉密业务场所应当安装****、****、****等安防系统,实行****管理。", + "question": "涉密业务场所应当安装****、****、****等安防系统,实行****管理。", "answers": ["门禁", "视频监控", "防盗报警", "封闭式"] }, { "id": "66", - "question": "66.资质单位每月至少对视频监控信息进行回看检查****次,视频监控信息保存时间不少于****个月。", + "question": "资质单位每月至少对视频监控信息进行回看检查****次,视频监控信息保存时间不少于****个月。", "answers": ["1", "3"] }, { "id": "67", - "question": "67.涉密业务场所应当确定人员进入范围,非授权人员进入应当履行****、****手续,并由接待人员****。", + "question": "涉密业务场所应当确定人员进入范围,非授权人员进入应当履行****、****手续,并由接待人员****。", "answers": ["审批", "登记", "全程陪同"] }, { "id": "68", - "question": "68.对进入涉密业务场所的****,应当采取相应的保密管理措施。", + "question": "对进入涉密业务场所的****,应当采取相应的保密管理措施。", "answers": ["工勤服务人员"] }, { "id": "69", - "question": "69.严禁将****带入涉密业务场所。未经批准,不得擅自将具有录音、录像、拍照、****、****功能的电子设备带入涉密业务场所。", + "question": "严禁将****带入涉密业务场所。未经批准,不得擅自将具有录音、录像、拍照、****、****功能的电子设备带入涉密业务场所。", "answers": ["手机", "存储", "通信"] }, { "id": "70", - "question": "70.涉密集成项目应当实行****管理,明确岗位责任,制定****,落实各个环节安全保密措施,确保管理全过程****。", + "question": "涉密集成项目应当实行****管理,明确岗位责任,制定****,落实各个环节安全保密措施,确保管理全过程****。", "answers": ["全过程", "保密工作方案", "可控可查"] }, { "id": "71", - "question": "71.资质单位应当按照涉密集成项目的密级,对用户需求文档、****、图纸、程序编码等技术资料和项目合同书、保密协议、****等业务资料是否属于国家秘密或者属于何种密级进行确定。", + "question": "资质单位应当按照涉密集成项目的密级,对用户需求文档、****、图纸、程序编码等技术资料和项目合同书、保密协议、****等业务资料是否属于国家秘密或者属于何种密级进行确定。", "answers": ["设计方案", "验收报告"] }, { "id": "72", - "question": "72.涉密集成项目实施期间,项目负责人对项目的安全保密负****,并按照工作需要,严格控制国家秘密载体的****知悉程度**。", + "question": "涉密集成项目实施期间,项目负责人对项目的安全保密负****,并按照工作需要,严格控制国家秘密载体的****知悉程度**。", "answers": ["总体责任", "接触范围和涉密信息的"] }, { "id": "73", - "question": "73.涉密集成项目组应当设置****,协助项目负责人开展涉密项目的保密管理工作。", + "question": "涉密集成项目组应当设置****,协助项目负责人开展涉密项目的保密管理工作。", "answers": ["保密员"] }, { "id": "74", - "question": "74.对参与涉密集成项目的管理人员、技术人员和工程施工人员应当进行****、登记备案,并对其****作出详细记录。", + "question": "对参与涉密集成项目的管理人员、技术人员和工程施工人员应当进行****、登记备案,并对其****作出详细记录。", "answers": ["保密教育", "分工"] }, { "id": "75", - "question": "75.从事涉密项目现场开发、工程施工、运行维护的人员应为本单位确定的****涉密人员。", + "question": "从事涉密项目现场开发、工程施工、运行维护的人员应为本单位确定的****涉密人员。", "answers": ["向委托方备案的"] }, { "id": "76", - "question": "76.涉密信息系统集成项目的设计方案、研发成果及有关建设情况,资质单位及其工作人员不得擅自以任何形式****、****或****。", + "question": "涉密信息系统集成项目的设计方案、研发成果及有关建设情况,资质单位及其工作人员不得擅自以任何形式****、****或****。", "answers": ["发表", "交流", "转让"] }, { "id": "77", - "question": "77.通过媒体、互联网等渠道对外发布信息,应当履行****手续。宣传报道、展览、公开发表****和论文,不得涉及承担的涉密集成项目。确需涉及背景、名称等敏感信息的,应当经****审核。", + "question": "通过媒体、互联网等渠道对外发布信息,应当履行****手续。宣传报道、展览、公开发表****和论文,不得涉及承担的涉密集成项目。确需涉及背景、名称等敏感信息的,应当经****审核。", "answers": ["审批", "著作", "委托方"] }, { "id": "78", - "question": "78.每年开展****保密风险评估,并制定****,对发现的保密风险隐患进行****,提出有针对性的防控措施。", + "question": "每年开展****保密风险评估,并制定****,对发现的保密风险隐患进行****,提出有针对性的防控措施。", "answers": ["1次", "评估方案", "分析和评估"] }, { "id": "79", - "question": "79.将保密风险隐患的防控措施融入到****和****监督检查**机制。", + "question": "将保密风险隐患的防控措施融入到****和****监督检查**机制。", "answers": ["管理制度", "业务工作流程中,并建立相应的"] }, { "id": "80", - "question": "80.保密工作经费分为****、****和****。", + "question": "保密工作经费分为****、****和****。", "answers": ["保密补贴", "保密管理经费", "保密专项经费"] } ], @@ -1549,7 +1549,7 @@ "typeName": "多选题" }, { - "type": "", + "type": "short-answer", "typeName": "简答题", "list": [ { diff --git a/scripts/import_questions.go b/scripts/import_questions.go new file mode 100644 index 0000000..2745efe --- /dev/null +++ b/scripts/import_questions.go @@ -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) + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 7e3ae0c..4ed26d7 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -14,13 +14,13 @@ const App: React.FC = () => { {/* 带TabBar的页面,需要登录保护 */} }> - } /> + } /> + } /> } /> {/* 不带TabBar的页面 */} } /> - } /> } /> diff --git a/web/src/api/question.ts b/web/src/api/question.ts index 03c0b52..05061ad 100644 --- a/web/src/api/question.ts +++ b/web/src/api/question.ts @@ -3,30 +3,43 @@ import type { Question, SubmitAnswer, AnswerResult, Statistics, ApiResponse } fr // 获取题目列表 export const getQuestions = (params?: { type?: string; category?: string }) => { - return request.get>('/questions', { params }) + return request.get>('/practice/questions', { params }) } // 获取随机题目 export const getRandomQuestion = () => { - return request.get>('/questions/random') + return request.get>('/practice/questions/random') } // 获取指定题目 export const getQuestionById = (id: number) => { - return request.get>(`/questions/${id}`) + return request.get>(`/practice/questions/${id}`) } // 提交答案 export const submitAnswer = (data: SubmitAnswer) => { - return request.post>('/submit', data) + return request.post>('/practice/submit', data) } -// 获取统计数据 -export const getStatistics = () => { - return request.get>('/statistics') +// 获取统计数据 (暂时返回模拟数据,后续实现) +export const getStatistics = async () => { + // TODO: 实现真实的统计接口 + return { + success: true, + data: { + total_questions: 0, + answered_questions: 0, + correct_answers: 0, + accuracy: 0, + } + } } -// 重置进度 -export const resetProgress = () => { - return request.post>('/reset') +// 重置进度 (暂时返回模拟数据,后续实现) +export const resetProgress = async () => { + // TODO: 实现真实的重置接口 + return { + success: true, + data: null + } } diff --git a/web/src/components/TabBarLayout.tsx b/web/src/components/TabBarLayout.tsx index 72f2066..b5c1c9f 100644 --- a/web/src/components/TabBarLayout.tsx +++ b/web/src/components/TabBarLayout.tsx @@ -4,6 +4,7 @@ import { TabBar } from 'antd-mobile' import { AppOutline, UserOutline, + UnorderedListOutline, } from 'antd-mobile-icons' import './TabBarLayout.less' @@ -17,6 +18,11 @@ const TabBarLayout: React.FC = () => { title: '首页', icon: , }, + { + key: '/question', + title: '答题', + icon: , + }, { key: '/profile', title: '我的', diff --git a/web/src/pages/Home.module.less b/web/src/pages/Home.module.less index df9c86d..3fa176a 100644 --- a/web/src/pages/Home.module.less +++ b/web/src/pages/Home.module.less @@ -1,18 +1,200 @@ -// 变量 -@bg-color: #f5f5f5; - -// 容器 .container { - display: flex; - flex-direction: column; - height: 100vh; - background-color: @bg-color; + min-height: 100vh; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + padding: 20px 16px; + padding-bottom: 80px; } -// 内容区域 -.content { - flex: 1; - overflow-y: auto; - padding: 16px; - padding-bottom: 60px; +.header { + text-align: center; + margin-bottom: 24px; + color: white; + + .title { + font-size: 32px; + font-weight: bold; + margin: 0; + margin-bottom: 8px; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + } + + .subtitle { + font-size: 14px; + margin: 0; + opacity: 0.9; + } +} + +.statsCard { + margin-bottom: 24px; + border-radius: 16px; + overflow: hidden; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + + :global { + .adm-card-body { + padding: 20px 16px; + } + } +} + +.statsContent { + display: flex; + justify-content: space-around; + align-items: center; +} + +.statItem { + flex: 1; + text-align: center; + + .statValue { + font-size: 28px; + font-weight: bold; + color: #1677ff; + margin-bottom: 4px; + } + + .statLabel { + font-size: 13px; + color: #666; + } +} + +.statDivider { + width: 1px; + height: 40px; + background: #e5e5e5; +} + +.typeSection { + margin-bottom: 24px; + + .sectionTitle { + font-size: 18px; + font-weight: bold; + color: white; + margin: 0 0 16px 0; + display: flex; + align-items: center; + gap: 8px; + } + + .typeGrid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; + } +} + +.typeCard { + border-radius: 16px; + overflow: hidden; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + transition: all 0.3s ease; + cursor: pointer; + + &:active { + transform: scale(0.95); + } + + :global { + .adm-card-body { + padding: 24px 16px; + text-align: center; + } + } + + .typeIcon { + margin-bottom: 12px; + display: flex; + justify-content: center; + align-items: center; + } + + .typeTitle { + font-size: 18px; + font-weight: bold; + color: #333; + margin-bottom: 8px; + } + + .typeDesc { + font-size: 13px; + color: #999; + } +} + +.quickStart { + .quickCard { + border-radius: 16px; + overflow: hidden; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + cursor: pointer; + transition: all 0.3s ease; + background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%); + + &:active { + transform: scale(0.98); + } + + :global { + .adm-card-body { + padding: 20px; + } + } + + .quickIcon { + font-size: 48px; + line-height: 1; + } + + .quickTitle { + font-size: 18px; + font-weight: bold; + color: #333; + margin-bottom: 4px; + } + + .quickDesc { + font-size: 13px; + color: #666; + } + } +} + +// 移动端适配 +@media (max-width: 768px) { + .container { + padding: 16px 12px; + padding-bottom: 70px; + } + + .header { + .title { + font-size: 28px; + } + } + + .statItem { + .statValue { + font-size: 24px; + } + } + + .typeCard { + :global { + .adm-card-body { + padding: 20px 12px; + } + } + + .typeIcon { + font-size: 40px; + } + + .typeTitle { + font-size: 16px; + } + } } diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx index 693ec3b..485a285 100644 --- a/web/src/pages/Home.tsx +++ b/web/src/pages/Home.tsx @@ -1,93 +1,175 @@ -import React from 'react' +import React, { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' import { - Button, - NavBar, - TabBar, - List, Card, + Toast, Space, - Toast } from 'antd-mobile' import { - AppOutline, + FileOutline, + CheckCircleOutline, UnorderedListOutline, - UserOutline + EditSOutline, } from 'antd-mobile-icons' +import * as questionApi from '../api/question' +import type { Statistics } from '../types/question' import styles from './Home.module.less' +// 题型配置 +const questionTypes = [ + { + key: 'single', + title: '单选题', + icon: , + color: '#1677ff', + description: '基础知识考察', + }, + { + key: 'multiple', + title: '多选题', + icon: , + color: '#52c41a', + description: '综合能力提升', + }, + { + key: 'judge', + title: '判断题', + icon: , + color: '#fa8c16', + description: '快速判断训练', + }, + { + key: 'fill', + title: '填空题', + icon: , + color: '#722ed1', + description: '填空补充练习', + }, + { + key: 'short', + title: '简答题', + icon: , + color: '#eb2f96', + description: '深度理解练习', + }, +] + const Home: React.FC = () => { const navigate = useNavigate() - const [activeKey, setActiveKey] = React.useState('home') + const [statistics, setStatistics] = useState({ + total_questions: 0, + answered_questions: 0, + correct_answers: 0, + accuracy: 0, + }) - const handleButtonClick = () => { - Toast.show({ - content: '欢迎使用 AnKao!', - duration: 2000, - }) + // 加载统计数据 + const loadStatistics = async () => { + try { + const res = await questionApi.getStatistics() + if (res.success && res.data) { + setStatistics(res.data) + } + } catch (error) { + console.error('加载统计失败:', error) + } } - const tabs = [ - { - key: 'home', - title: '首页', - icon: , - }, - { - key: 'list', - title: '列表', - icon: , - }, - { - key: 'profile', - title: '我的', - icon: , - }, - ] + useEffect(() => { + loadStatistics() + }, []) + + // 点击题型卡片 + const handleTypeClick = async (type: string) => { + try { + // 加载该题型的题目列表 + const res = await questionApi.getQuestions({ type }) + if (res.success && res.data && res.data.length > 0) { + // 跳转到答题页面,并传递题型参数 + navigate(`/question?type=${type}`) + Toast.show({ + icon: 'success', + content: `开始${questionTypes.find(t => t.key === type)?.title}练习`, + }) + } else { + Toast.show({ + icon: 'fail', + content: '该题型暂无题目', + }) + } + } catch (error) { + Toast.show({ + icon: 'fail', + content: '加载题目失败', + }) + } + } return (
- AnKao + {/* 头部 */} +
+

AnKao 刷题

+

选择题型开始练习

+
-
- - -

这是一个基于 React + TypeScript + Vite + antd-mobile 构建的移动端应用

+ {/* 统计卡片 */} + +
+
+
{statistics.total_questions}
+
题库总数
+
+
+
+
{statistics.answered_questions}
+
已答题数
+
+
+
+
{statistics.accuracy.toFixed(0)}%
+
正确率
+
+
+
- - ⚛️ React 18 - 📘 TypeScript - ⚡ Vite - 📱 antd-mobile 5 - 🔧 Go Gin 后端 - - - +
+ {type.icon} +
+
{type.title}
+
{type.description}
+
+ ))} +
+
- + {/* 快速开始 */} +
+ navigate('/question')} + > + +
🎲
+
+
随机练习
+
从所有题型中随机抽取
+
- - - {tabs.map(item => ( - - ))} - ) } diff --git a/web/src/pages/Question.module.less b/web/src/pages/Question.module.less index 4b2b79c..a9bb9ce 100644 --- a/web/src/pages/Question.module.less +++ b/web/src/pages/Question.module.less @@ -68,6 +68,34 @@ 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; + } + } + } + } } // 答案结果 diff --git a/web/src/pages/Question.tsx b/web/src/pages/Question.tsx index 7af1d31..ef28389 100644 --- a/web/src/pages/Question.tsx +++ b/web/src/pages/Question.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect } from 'react' +import { useSearchParams } from 'react-router-dom' import { Button, Card, @@ -25,6 +26,7 @@ import * as questionApi from '../api/question' import './Question.module.less' const QuestionPage: React.FC = () => { + const [searchParams] = useSearchParams() const [currentQuestion, setCurrentQuestion] = useState(null) const [selectedAnswer, setSelectedAnswer] = useState('') const [showResult, setShowResult] = useState(false) @@ -32,6 +34,7 @@ const QuestionPage: React.FC = () => { const [loading, setLoading] = useState(false) const [allQuestions, setAllQuestions] = useState([]) const [currentIndex, setCurrentIndex] = useState(0) + const [fillAnswers, setFillAnswers] = useState([]) // 填空题答案数组 // 统计弹窗 const [statsVisible, setStatsVisible] = useState(false) @@ -58,6 +61,7 @@ const QuestionPage: React.FC = () => { if (res.success && res.data) { setCurrentQuestion(res.data) setSelectedAnswer(res.data.type === 'multiple' ? [] : '') + setFillAnswers([]) setShowResult(false) setAnswerResult(null) } @@ -79,6 +83,7 @@ const QuestionPage: React.FC = () => { setCurrentQuestion(res.data[0]) setCurrentIndex(0) setSelectedAnswer(res.data[0].type === 'multiple' ? [] : '') + setFillAnswers([]) setShowResult(false) setAnswerResult(null) } @@ -107,12 +112,24 @@ const QuestionPage: React.FC = () => { if (!currentQuestion) return // 检查是否选择了答案 - if ( - (currentQuestion.type === 'multiple' && (selectedAnswer as string[]).length === 0) || - (currentQuestion.type !== 'multiple' && !selectedAnswer) - ) { - Toast.show('请选择或填写答案') - return + if (currentQuestion.type === 'multiple') { + // 多选题:检查数组长度 + if ((selectedAnswer as string[]).length === 0) { + Toast.show('请选择答案') + return + } + } else if (currentQuestion.type === 'fill') { + // 填空题:检查所有填空是否都已填写 + if (fillAnswers.length === 0 || fillAnswers.some(a => !a || a.trim() === '')) { + Toast.show('请填写所有空格') + return + } + } else { + // 单选题、判断题、简答题:检查是否有值 + if (!selectedAnswer || (typeof selectedAnswer === 'string' && selectedAnswer.trim() === '')) { + Toast.show('请填写答案') + return + } } setLoading(true) @@ -146,6 +163,7 @@ const QuestionPage: React.FC = () => { setCurrentIndex(nextIndex) setCurrentQuestion(allQuestions[nextIndex]) setSelectedAnswer(allQuestions[nextIndex].type === 'multiple' ? [] : '') + setFillAnswers([]) setShowResult(false) setAnswerResult(null) } else { @@ -158,6 +176,7 @@ const QuestionPage: React.FC = () => { setCurrentQuestion(question) setCurrentIndex(index) setSelectedAnswer(question.type === 'multiple' ? [] : '') + setFillAnswers([]) setShowResult(false) setAnswerResult(null) setListVisible(false) @@ -188,9 +207,19 @@ const QuestionPage: React.FC = () => { // 初始化 useEffect(() => { - loadRandomQuestion() - loadQuestions() - }, []) + // 从URL参数获取题型 + const typeParam = searchParams.get('type') + const categoryParam = searchParams.get('category') + + if (typeParam || categoryParam) { + // 如果有筛选参数,加载筛选后的题目列表 + loadQuestions(typeParam || undefined, categoryParam || undefined) + } else { + // 否则加载随机题目 + loadRandomQuestion() + loadQuestions() + } + }, [searchParams]) // 获取题型名称 const getTypeName = (type: string) => { @@ -199,22 +228,78 @@ const QuestionPage: React.FC = () => { multiple: '多选题', fill: '填空题', judge: '判断题', + short: '简答题', } return typeMap[type] || type } + // 渲染填空题内容(将****替换为输入框) + const renderFillContent = () => { + if (!currentQuestion) return null + + const content = currentQuestion.content + const parts = content.split('****') + + // 如果没有****,说明不是填空题格式 + if (parts.length === 1) { + return
{content}
+ } + + // 初始化填空答案数组 + if (fillAnswers.length === 0) { + setFillAnswers(new Array(parts.length - 1).fill('')) + } + + return ( +
+ {parts.map((part, index) => ( + + {part} + {index < parts.length - 1 && ( + { + const newAnswers = [...fillAnswers] + newAnswers[index] = val + setFillAnswers(newAnswers) + setSelectedAnswer(newAnswers) + }} + disabled={showResult} + style={{ + display: 'inline-block', + width: '120px', + margin: '0 8px', + borderBottom: '2px solid #1677ff', + borderRadius: 0, + }} + /> + )} + + ))} +
+ ) + } + // 渲染题目选项 const renderOptions = () => { if (!currentQuestion) return null + // 填空题:使用特殊渲染(在题目内容中嵌入输入框) if (currentQuestion.type === 'fill') { + return null // 填空题的输入框已在题目内容中渲染 + } + + // 简答题:使用大文本框 + if (currentQuestion.type === 'short') { return ( setSelectedAnswer(val)} disabled={showResult} - style={{ marginTop: 20 }} + style={{ marginTop: 20, minHeight: 100 }} /> ) } @@ -289,7 +374,10 @@ const QuestionPage: React.FC = () => {
第 {currentQuestion.id} 题
-
{currentQuestion.content}
+ {/* 填空题使用特殊渲染 */} + {currentQuestion.type === 'fill' ? renderFillContent() : ( +
{currentQuestion.content}
+ )} {renderOptions()} @@ -437,6 +525,7 @@ const QuestionPage: React.FC = () => { { label: '多选题', value: 'multiple' }, { label: '填空题', value: 'fill' }, { label: '判断题', value: 'judge' }, + { label: '简答题', value: 'short' }, ]} value={[filterType]} onChange={(arr) => setFilterType(arr[0] || '')} diff --git a/web/src/types/question.ts b/web/src/types/question.ts index 4f51be5..a6a65f4 100644 --- a/web/src/types/question.ts +++ b/web/src/types/question.ts @@ -1,5 +1,5 @@ // 题目类型 -export type QuestionType = 'single' | 'multiple' | 'fill' | 'judge' +export type QuestionType = 'single' | 'multiple' | 'fill' | 'judge' | 'short' // 选项 export interface Option {