添加论述题权限控制系统和AI评分功能

本次更新实现了基于用户类型的论述题访问权限控制,并为论述题添加了专门的AI评分功能。

后端更新:
- 添加论述题权限验证:根据用户类型(ordinary-person/management-person)控制不同论述题的访问权限
- 新增 GradeEssay 方法:为论述题提供专门的AI评分,不依赖标准答案,基于保密法规进行专业评分
- 优化AI评分提示词:增加法规依据要求,返回参考答案、评分依据等更详细的评分信息
- 添加用户类型管理:新增 UpdateUserType API,支持用户更新个人类型
- 路由调整:将练习题相关API移至需要认证的路由组

前端更新:
- 论述题答题界面优化:不显示标准答案,展示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-07 16:47:37 +08:00
parent 2e526425a0
commit 3b7133d9de
14 changed files with 805 additions and 140 deletions

View File

@ -16,11 +16,65 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// checkEssayPermission 检查用户是否有权限访问论述题
// 返回 true 表示有权限false 表示无权限
func checkEssayPermission(c *gin.Context, questionType string) bool {
// 如果不是论述题,直接允许访问
if !strings.HasSuffix(questionType, "-essay") {
log.Printf("[论述题权限检查] 非论述题类型,直接允许访问 (type: %s)", questionType)
return true
}
log.Printf("[论述题权限检查] 开始检查 (question_type: %s)", questionType)
// 从上下文获取用户信息Auth中间件已设置
username, exists := c.Get("username")
if !exists {
log.Printf("[论述题权限检查] 失败: 未登录用户 (question_type: %s)", questionType)
return false
}
log.Printf("[论述题权限检查] 已登录用户 (username: %v, question_type: %s)", username, questionType)
// 管理员可以访问所有论述题
if username == "yanlongqi" {
log.Printf("[论述题权限检查] 通过: 管理员用户,允许访问所有论述题 (username: %s)", username)
return true
}
// 获取用户信息
db := database.GetDB()
var user models.User
if err := db.Where("username = ?", username).First(&user).Error; err != nil {
log.Printf("[论述题权限检查] 失败: 查询用户失败 (username: %v, error: %v)", username, err)
return false
}
log.Printf("[论述题权限检查] 用户信息 (username: %s, user_type: '%s', question_type: '%s')",
user.Username, user.UserType, questionType)
// 检查用户类型是否匹配
if questionType == "ordinary-essay" && user.UserType == "ordinary-person" {
log.Printf("[论述题权限检查] 通过: 普通涉密人员访问普通论述题 (username: %s)", user.Username)
return true
}
if questionType == "management-essay" && user.UserType == "management-person" {
log.Printf("[论述题权限检查] 通过: 保密管理人员访问管理论述题 (username: %s)", user.Username)
return true
}
log.Printf("[论述题权限检查] 失败: 类型不匹配 (username: %s, user_type: '%s', question_type: '%s')",
user.Username, user.UserType, questionType)
return false
}
// GetPracticeQuestions 获取练习题目列表 // GetPracticeQuestions 获取练习题目列表
func GetPracticeQuestions(c *gin.Context) { func GetPracticeQuestions(c *gin.Context) {
typeParam := c.Query("type") typeParam := c.Query("type")
searchQuery := c.Query("search") searchQuery := c.Query("search")
log.Printf("[GetPracticeQuestions] 收到请求 (type: '%s', search: '%s')", typeParam, searchQuery)
db := database.GetDB() db := database.GetDB()
var questions []models.PracticeQuestion var questions []models.PracticeQuestion
var total int64 var total int64
@ -29,7 +83,19 @@ func GetPracticeQuestions(c *gin.Context) {
// 根据题型过滤 // 根据题型过滤
if typeParam != "" { if typeParam != "" {
// 如果请求论述题,检查权限
if strings.HasSuffix(typeParam, "-essay") && !checkEssayPermission(c, typeParam) {
// 权限不足,返回空列表
log.Printf("[GetPracticeQuestions] 权限检查失败,返回空列表 (type: '%s')", typeParam)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": []models.PracticeQuestionDTO{},
"total": 0,
})
return
}
query = query.Where("type = ?", typeParam) query = query.Where("type = ?", typeParam)
log.Printf("[GetPracticeQuestions] 添加题型过滤 (type: '%s')", typeParam)
} }
// 根据搜索关键词过滤(搜索题目内容或题目编号) // 根据搜索关键词过滤(搜索题目内容或题目编号)
@ -40,10 +106,14 @@ func GetPracticeQuestions(c *gin.Context) {
// 获取总数 // 获取总数
query.Count(&total) query.Count(&total)
log.Printf("[GetPracticeQuestions] 查询结果统计 (total: %d)", total)
// 查询所有题目 - 按题型和题目编号升序排序 // 查询所有题目 - 按题型和题目编号升序排序
// 先将 question_id 转为文本,提取数字部分,再转为整数排序 // 先将 question_id 转为文本,提取数字部分,再转为整数排序
err := query.Order("type ASC, CAST(COALESCE(NULLIF(REGEXP_REPLACE(question_id::text, '[^0-9]', '', 'g'), ''), '0') AS INTEGER) ASC").Find(&questions).Error // 显式查询所有字段,包括 answer_data
err := query.Select("*").Order("type ASC, CAST(COALESCE(NULLIF(REGEXP_REPLACE(question_id::text, '[^0-9]', '', 'g'), ''), '0') AS INTEGER) ASC").Find(&questions).Error
if err != nil { if err != nil {
log.Printf("[GetPracticeQuestions] 查询失败 (error: %v)", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
"message": "查询题目失败", "message": "查询题目失败",
@ -51,6 +121,8 @@ func GetPracticeQuestions(c *gin.Context) {
return return
} }
log.Printf("[GetPracticeQuestions] 查询成功,返回 %d 条数据", len(questions))
// 转换为DTO // 转换为DTO
dtos := make([]models.PracticeQuestionDTO, len(questions)) dtos := make([]models.PracticeQuestionDTO, len(questions))
for i, q := range questions { for i, q := range questions {
@ -101,16 +173,30 @@ func GetPracticeQuestionByID(c *gin.Context) {
func GetRandomPracticeQuestion(c *gin.Context) { func GetRandomPracticeQuestion(c *gin.Context) {
typeParam := c.Query("type") typeParam := c.Query("type")
log.Printf("[GetRandomPracticeQuestion] 收到请求 (type: '%s')", typeParam)
db := database.GetDB() db := database.GetDB()
var question models.PracticeQuestion var question models.PracticeQuestion
query := db.Model(&models.PracticeQuestion{}) query := db.Model(&models.PracticeQuestion{})
if typeParam != "" { if typeParam != "" {
// 如果请求论述题,检查权限
if strings.HasSuffix(typeParam, "-essay") && !checkEssayPermission(c, typeParam) {
// 权限不足,返回暂无题目
log.Printf("[GetRandomPracticeQuestion] 权限检查失败,返回暂无题目 (type: '%s')", typeParam)
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"message": "暂无题目",
})
return
}
query = query.Where("type = ?", typeParam) query = query.Where("type = ?", typeParam)
log.Printf("[GetRandomPracticeQuestion] 添加题型过滤 (type: '%s')", typeParam)
} }
// 使用PostgreSQL的随机排序 // 使用PostgreSQL的随机排序
if err := query.Order("RANDOM()").First(&question).Error; err != nil { if err := query.Order("RANDOM()").First(&question).Error; err != nil {
log.Printf("[GetRandomPracticeQuestion] 未找到题目 (type: '%s', error: %v)", typeParam, err)
c.JSON(http.StatusNotFound, gin.H{ c.JSON(http.StatusNotFound, gin.H{
"success": false, "success": false,
"message": "暂无题目", "message": "暂无题目",
@ -118,6 +204,8 @@ func GetRandomPracticeQuestion(c *gin.Context) {
return return
} }
log.Printf("[GetRandomPracticeQuestion] 找到题目 (id: %d, type: '%s')", question.ID, question.Type)
// 转换为DTO // 转换为DTO
dto := convertToDTO(question) dto := convertToDTO(question)
@ -159,40 +247,56 @@ func SubmitPracticeAnswer(c *gin.Context) {
return return
} }
// 解析正确答案 // 解析正确答案(论述题不需要标准答案)
var correctAnswer interface{} var correctAnswer interface{}
if err := json.Unmarshal([]byte(question.AnswerData), &correctAnswer); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ // 论述题跳过答案解析
"success": false, if strings.HasSuffix(question.Type, "-essay") {
"message": "答案数据错误", log.Printf("[论述题] 跳过答案解析 (题目ID: %d, 类型: %s)", question.ID, question.Type)
}) correctAnswer = "" // 论述题没有标准答案
return } else {
// 其他题型需要解析标准答案
if question.AnswerData == "" {
log.Printf("[错误] 题目缺少答案数据 (题目ID: %d, 类型: %s)", question.ID, question.Type)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "题目答案数据缺失",
})
return
}
if err := json.Unmarshal([]byte(question.AnswerData), &correctAnswer); err != nil {
log.Printf("[错误] 答案数据解析失败 (题目ID: %d, 类型: %s, AnswerData: '%s', 错误: %v)",
question.ID, question.Type, question.AnswerData, err)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "答案数据错误",
})
return
}
} }
// 验证答案 // 验证答案(论述题不需要验证,因为没有标准答案)
correct := checkPracticeAnswer(question.Type, submit.Answer, correctAnswer) correct := false
if strings.HasSuffix(question.Type, "-essay") {
// 论述题默认为false实际结果由AI评分决定
correct = false
} else {
// 其他题型使用标准答案验证
correct = checkPracticeAnswer(question.Type, submit.Answer, correctAnswer)
}
// AI评分结果仅简答题 // AI评分结果简答题和论述题)
var aiGrading *models.AIGrading = nil var aiGrading *models.AIGrading = nil
// 对简答题使用AI评分必须成功失败重试最多5次 // 对简答题和论述题使用AI评分必须成功失败重试最多5次
if question.Type == "short-answer" { if question.Type == "short-answer" || strings.HasSuffix(question.Type, "-essay") {
// 获取用户答案字符串 // 获取用户答案字符串
userAnswerStr, ok := submit.Answer.(string) userAnswerStr, ok := submit.Answer.(string)
if !ok { if !ok {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"success": false, "success": false,
"message": "简答题答案格式错误", "message": "答案格式错误",
})
return
}
// 获取标准答案字符串
standardAnswerStr, ok := correctAnswer.(string)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "题目答案格式错误",
}) })
return return
} }
@ -203,17 +307,46 @@ func SubmitPracticeAnswer(c *gin.Context) {
var err error var err error
maxRetries := 5 maxRetries := 5
for attempt := 1; attempt <= maxRetries; attempt++ { // 区分简答题和论述题的评分方式
log.Printf("AI评分尝试第 %d 次 (题目ID: %d)", attempt, question.ID) if strings.HasSuffix(question.Type, "-essay") {
aiResult, err = aiService.GradeShortAnswer(question.Question, standardAnswerStr, userAnswerStr) // 论述题:不需要标准答案,直接评分
if err == nil { for attempt := 1; attempt <= maxRetries; attempt++ {
log.Printf("AI评分成功 (题目ID: %d, 得分: %.1f)", question.ID, aiResult.Score) log.Printf("论述题AI评分尝试第 %d 次 (题目ID: %d)", attempt, question.ID)
break aiResult, err = aiService.GradeEssay(question.Question, userAnswerStr)
if err == nil {
log.Printf("论述题AI评分成功 (题目ID: %d, 得分: %.1f)", question.ID, aiResult.Score)
break
}
log.Printf("论述题AI评分失败 (第 %d 次尝试): %v", attempt, err)
if attempt < maxRetries {
// 等待一小段时间后重试(指数退避)
time.Sleep(time.Second * time.Duration(attempt))
}
} }
log.Printf("AI评分失败 (第 %d 次尝试): %v", attempt, err) } else {
if attempt < maxRetries { // 简答题:需要标准答案对比
// 等待一小段时间后重试(指数退避) // 获取标准答案字符串
time.Sleep(time.Second * time.Duration(attempt)) standardAnswerStr, ok := correctAnswer.(string)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "题目答案格式错误",
})
return
}
for attempt := 1; attempt <= maxRetries; attempt++ {
log.Printf("简答题AI评分尝试第 %d 次 (题目ID: %d)", attempt, question.ID)
aiResult, err = aiService.GradeShortAnswer(question.Question, standardAnswerStr, userAnswerStr)
if err == nil {
log.Printf("简答题AI评分成功 (题目ID: %d, 得分: %.1f)", question.ID, aiResult.Score)
break
}
log.Printf("简答题AI评分失败 (第 %d 次尝试): %v", attempt, err)
if attempt < maxRetries {
// 等待一小段时间后重试(指数退避)
time.Sleep(time.Second * time.Duration(attempt))
}
} }
} }
@ -228,9 +361,11 @@ func SubmitPracticeAnswer(c *gin.Context) {
// 使用AI的评分结果 // 使用AI的评分结果
correct = aiResult.IsCorrect correct = aiResult.IsCorrect
aiGrading = &models.AIGrading{ aiGrading = &models.AIGrading{
Score: aiResult.Score, Score: aiResult.Score,
Feedback: aiResult.Feedback, Feedback: aiResult.Feedback,
Suggestion: aiResult.Suggestion, Suggestion: aiResult.Suggestion,
ReferenceAnswer: aiResult.ReferenceAnswer,
ScoringRationale: aiResult.ScoringRationale,
} }
} }
@ -248,22 +383,34 @@ func SubmitPracticeAnswer(c *gin.Context) {
} }
} }
// 如果答错,记录到错题本 // 如果答错,记录到错题本(论述题也可能答错)
if !correct { if !correct {
if uid, ok := userID.(uint); ok { if uid, ok := userID.(uint); ok {
// 记录错题 // 记录错题
if err := recordWrongQuestion(uid, question.ID, submit.Answer, correctAnswer); err != nil { // 论述题没有 correctAnswer传 nil
wrongAnswer := correctAnswer
if strings.HasSuffix(question.Type, "-essay") {
wrongAnswer = "" // 论述题没有标准答案
}
if err := recordWrongQuestion(uid, question.ID, submit.Answer, wrongAnswer); err != nil {
// 记录错题失败不影响主流程,只记录日志 // 记录错题失败不影响主流程,只记录日志
log.Printf("记录错题失败: %v", err) log.Printf("记录错题失败: %v", err)
} }
} }
} }
// 构建返回结果
result := models.PracticeAnswerResult{ result := models.PracticeAnswerResult{
Correct: correct, Correct: correct,
UserAnswer: submit.Answer, UserAnswer: submit.Answer,
CorrectAnswer: correctAnswer, // 始终返回正确答案 AIGrading: aiGrading, // AI评分结果简答题和论述题
AIGrading: aiGrading, // AI评分结果仅简答题有值 }
// 论述题不返回标准答案(因为没有固定答案)
if !strings.HasSuffix(question.Type, "-essay") {
result.CorrectAnswer = correctAnswer
} else {
result.CorrectAnswer = "" // 论述题返回空字符串
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
@ -295,6 +442,14 @@ func GetPracticeQuestionTypes(c *gin.Context) {
"type": "short-answer", "type": "short-answer",
"type_name": "简答题", "type_name": "简答题",
}, },
{
"type": "ordinary-essay",
"type_name": "普通涉密人员论述题",
},
{
"type": "management-essay",
"type_name": "保密管理人员论述题",
},
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
@ -402,9 +557,28 @@ func convertToDTO(question models.PracticeQuestion) models.PracticeQuestionDTO {
} }
// 解析答案数据 // 解析答案数据
var answer interface{} if question.AnswerData != "" {
if err := json.Unmarshal([]byte(question.AnswerData), &answer); err == nil { // 对于简答题和论述题答案可能是纯字符串或JSON格式
dto.Answer = answer if question.Type == "short-answer" || strings.HasSuffix(question.Type, "-essay") {
// 尝试JSON解析
var answer interface{}
if err := json.Unmarshal([]byte(question.AnswerData), &answer); err != nil {
// JSON解析失败直接使用原始字符串
dto.Answer = question.AnswerData
} else {
// JSON解析成功
dto.Answer = answer
}
} else {
// 其他题型必须是JSON格式
var answer interface{}
if err := json.Unmarshal([]byte(question.AnswerData), &answer); err != nil {
log.Printf("[convertToDTO] 解析答案失败 (id: %d, type: %s, error: %v)",
question.ID, question.Type, err)
} else {
dto.Answer = answer
}
}
} }
// 判断题自动生成选项(正确在前,错误在后) // 判断题自动生成选项(正确在前,错误在后)

View File

@ -81,8 +81,12 @@ func Login(c *gin.Context) {
Username: user.Username, Username: user.Username,
Avatar: user.Avatar, Avatar: user.Avatar,
Nickname: user.Nickname, Nickname: user.Nickname,
UserType: user.UserType, // 返回用户类型
} }
// 检查用户类型是否为空,如果为空,标识需要补充
needUserType := user.UserType == ""
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "登录成功", "message": "登录成功",
@ -90,6 +94,7 @@ func Login(c *gin.Context) {
Token: token, Token: token,
User: userInfo, User: userInfo,
}, },
"need_user_type": needUserType, // 添加标识,前端根据此标识显示补充弹窗
}) })
} }
@ -123,6 +128,7 @@ func Register(c *gin.Context) {
newUser := models.User{ newUser := models.User{
Username: req.Username, Username: req.Username,
Nickname: req.Nickname, Nickname: req.Nickname,
UserType: req.UserType, // 保存用户类型
Avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=" + req.Username, // 使用用户名生成默认头像 Avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=" + req.Username, // 使用用户名生成默认头像
} }
@ -162,6 +168,7 @@ func Register(c *gin.Context) {
Username: newUser.Username, Username: newUser.Username,
Avatar: newUser.Avatar, Avatar: newUser.Avatar,
Nickname: newUser.Nickname, Nickname: newUser.Nickname,
UserType: newUser.UserType, // 返回用户类型
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
@ -173,3 +180,67 @@ func Register(c *gin.Context) {
}, },
}) })
} }
// UpdateUserTypeRequest 更新用户类型请求
type UpdateUserTypeRequest struct {
UserType string `json:"user_type" binding:"required,oneof=ordinary-person management-person"`
}
// UpdateUserType 更新用户类型
func UpdateUserType(c *gin.Context) {
var req UpdateUserTypeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "请求参数错误,用户类型必须是 ordinary-person 或 management-person",
"error": err.Error(),
})
return
}
// 从上下文获取用户信息(由认证中间件设置)
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未授权访问",
})
return
}
db := database.GetDB()
var user models.User
if err := db.Where("username = ?", username).First(&user).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"message": "用户不存在",
})
return
}
// 更新用户类型
user.UserType = req.UserType
if err := db.Save(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "更新用户类型失败",
"error": err.Error(),
})
return
}
// 返回更新后的用户信息
userInfo := models.UserInfoResponse{
Username: user.Username,
Avatar: user.Avatar,
Nickname: user.Nickname,
UserType: user.UserType,
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "用户类型更新成功",
"data": userInfo,
})
}

View File

@ -45,7 +45,9 @@ type PracticeAnswerResult struct {
// AIGrading AI评分结果 // AIGrading AI评分结果
type AIGrading struct { type AIGrading struct {
Score float64 `json:"score"` // 得分 (0-100) Score float64 `json:"score"` // 得分 (0-100)
Feedback string `json:"feedback"` // 评语 Feedback string `json:"feedback"` // 评语
Suggestion string `json:"suggestion"` // 改进建议 Suggestion string `json:"suggestion"` // 改进建议
ReferenceAnswer string `json:"reference_answer,omitempty"` // 参考答案(论述题)
ScoringRationale string `json:"scoring_rationale,omitempty"` // 评分依据
} }

View File

@ -15,6 +15,7 @@ type User struct {
Token string `gorm:"size:255;index" json:"-"` // 用户登录token Token string `gorm:"size:255;index" json:"-"` // 用户登录token
Avatar string `gorm:"size:255" json:"avatar"` Avatar string `gorm:"size:255" json:"avatar"`
Nickname string `gorm:"size:50" json:"nickname"` Nickname string `gorm:"size:50" json:"nickname"`
UserType string `gorm:"size:50" json:"user_type"` // 用户类型: ordinary-person 或 management-person
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
@ -56,6 +57,7 @@ type UserInfoResponse struct {
Username string `json:"username"` Username string `json:"username"`
Avatar string `json:"avatar"` Avatar string `json:"avatar"`
Nickname string `json:"nickname"` Nickname string `json:"nickname"`
UserType string `json:"user_type"` // 用户类型
} }
// RegisterRequest 注册请求 // RegisterRequest 注册请求
@ -63,4 +65,5 @@ type RegisterRequest struct {
Username string `json:"username" binding:"required"` Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required,min=6"` Password string `json:"password" binding:"required,min=6"`
Nickname string `json:"nickname"` Nickname string `json:"nickname"`
UserType string `json:"user_type" binding:"required,oneof=ordinary-person management-person"` // 用户类型,必填
} }

View File

@ -36,49 +36,71 @@ func NewAIGradingService() *AIGradingService {
// AIGradingResult AI评分结果 // AIGradingResult AI评分结果
type AIGradingResult struct { type AIGradingResult struct {
Score float64 `json:"score"` // 得分 (0-100) Score float64 `json:"score"` // 得分 (0-100)
IsCorrect bool `json:"is_correct"` // 是否正确 (Score >= 60 视为正确) IsCorrect bool `json:"is_correct"` // 是否正确 (Score >= 60 视为正确)
Feedback string `json:"feedback"` // 评语 Feedback string `json:"feedback"` // 评语
Suggestion string `json:"suggestion"` // 改进建议 Suggestion string `json:"suggestion"` // 改进建议
ReferenceAnswer string `json:"reference_answer"` // 参考答案(论述题)
ScoringRationale string `json:"scoring_rationale"` // 评分依据
} }
// GradeShortAnswer 对简答题进行AI评分 // GradeEssay 对论述题进行AI评分不需要标准答案
// question: 题目内容 // question: 题目内容
// standardAnswer: 标准答案
// userAnswer: 用户答案 // userAnswer: 用户答案
func (s *AIGradingService) GradeShortAnswer(question, standardAnswer, userAnswer string) (*AIGradingResult, error) { func (s *AIGradingService) GradeEssay(question, userAnswer string) (*AIGradingResult, error) {
// 构建评分提示词(严格评分模式) // 构建评分提示词(论述题评分模式)
prompt := fmt.Sprintf(`你是一位严格的阅卷老师请严格按照标准答案对以下简答题进行评分 prompt := fmt.Sprintf(`你是一位专业的保密领域阅卷老师请对以下论述题进行评分
题目%s 题目%s
标准答案%s
学生答案%s 学生答案%s
评分标准请严格遵守 评分依据
1. 必须与标准答案进行逐项对比 请依据以下保密法律法规和管理制度进行分析和评分
2. 答案要点完全覆盖标准答案且表述准确的给85-100 1. 中华人民共和国保守国家秘密法
3. 答案要点基本覆盖但有缺漏或表述不够准确的给60-84 2. 中华人民共和国保守国家秘密法实施条例
4. 答案要点缺失较多或有明显错误的给40-59 3. 保密工作管理制度2025.9.9
5. 答案完全错误或离题的给0-39 4. 软件开发管理制度
6. 判断标准60分及以上为正确is_correct: true否则为错误is_correct: false 5. 涉密信息系统集成资质保密标准
6. 涉密信息系统集成资质管理办法
评分标准论述题没有固定标准答案请根据答题质量和法规符合度评分
1. 论点是否明确是否符合保密法规要求30
2. 内容是否充实论据是否引用相关法规条文30
3. 逻辑是否严密分析是否符合保密工作实际25
4. 语言表达是否准确专业15
评分等级
- 85-100论述优秀论点明确论据充分符合法规要求分析专业
- 70-84论述良好基本要素齐全符合保密工作要求
- 60-69论述基本合格要点基本涵盖但不够深入
- 40-59论述不够完整缺乏法规支撑或逻辑性较差
- 0-39论述严重缺失或完全离题
判断标准60分及以上为正确is_correct: true否则为错误is_correct: false
评分要求 评分要求
1. 给出一个0-100的精确分数 1. 给出一个0-100的精确分数
2. 判断答案是否正确is_correct: 60分及以上为true否则为false 2. 判断答案是否正确is_correct: 60分及以上为true否则为false
3. 给出简短的评语说明得分和失分原因不超过50字 3. 生成一个专业的参考答案reference_answer150-300必须引用相关法规条文
4. 给出具体的改进建议如果答案满分可以为空否则必须指出具体改进方向不超过50字 4. 给出评分依据scoring_rationale说明依据了哪些法规和条文80-150
5. 给出简短的评语feedback说明得分情况不超过80字
6. 给出具体的改进建议suggestion如果分数在90分以上可以简短否则必须指出具体改进方向不超过80字
请按照以下JSON格式返回结果 请按照以下JSON格式返回结果
{ {
"score": 85, "score": 75,
"is_correct": true, "is_correct": true,
"feedback": "答案覆盖了主要要点但XXX部分描述不够准确", "reference_answer": "根据《中华人民共和国保守国家秘密法》第XX条...",
"suggestion": "建议补充XXX内容并完善XXX的描述" "scoring_rationale": "依据《保密法》第XX条、《保密法实施条例》第XX条...",
"feedback": "论述较为完整,论点明确,但论据不够充分,缺少具体法规引用",
"suggestion": "建议补充《保密法》相关条文,加强论点之间的逻辑联系"
} }
注意只返回JSON格式的结果不要有其他内容必须严格对照标准答案评分不要过于宽松`, question, standardAnswer, userAnswer) 注意
1. 只返回JSON格式的结果不要有其他内容
2. 参考答案必须专业准确体现保密法规要求
3. 评分依据必须具体引用法规条文`, question, userAnswer)
// 调用AI API // 调用AI API
resp, err := s.client.CreateChatCompletion( resp, err := s.client.CreateChatCompletion(
@ -88,7 +110,97 @@ func (s *AIGradingService) GradeShortAnswer(question, standardAnswer, userAnswer
Messages: []openai.ChatCompletionMessage{ Messages: []openai.ChatCompletionMessage{
{ {
Role: openai.ChatMessageRoleSystem, Role: openai.ChatMessageRoleSystem,
Content: "你是一位严格的阅卷老师,必须严格按照标准答案进行评分,不能过于宽松。你的评分标准是客观的、一致的、可预测的。", 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
}
// GradeShortAnswer 对简答题进行AI评分
// question: 题目内容
// standardAnswer: 标准答案
// userAnswer: 用户答案
func (s *AIGradingService) GradeShortAnswer(question, standardAnswer, userAnswer string) (*AIGradingResult, error) {
// 构建评分提示词(严格评分模式)
prompt := fmt.Sprintf(`你是一位专业的保密领域阅卷老师请严格按照标准答案对以下简答题进行评分
题目%s
标准答案%s
学生答案%s
评分依据
请依据以下保密法律法规和管理制度进行分析和评分
1. 中华人民共和国保守国家秘密法
2. 中华人民共和国保守国家秘密法实施条例
3. 保密工作管理制度2025.9.9
4. 软件开发管理制度
5. 涉密信息系统集成资质保密标准
6. 涉密信息系统集成资质管理办法
评分标准请严格遵守
1. 必须与标准答案进行逐项对比
2. 答案要点完全覆盖标准答案且表述准确符合法规要求的给85-100
3. 答案要点基本覆盖但有缺漏或表述不够准确的给60-84
4. 答案要点缺失较多或有明显错误的给40-59
5. 答案完全错误或离题的给0-39
6. 判断标准60分及以上为正确is_correct: true否则为错误is_correct: false
评分要求
1. 给出一个0-100的精确分数
2. 判断答案是否正确is_correct: 60分及以上为true否则为false
3. 给出评分依据scoring_rationale说明依据了哪些法规和标准答案的哪些要点80-150
4. 给出简短的评语feedback说明得分和失分原因不超过80字
5. 给出具体的改进建议suggestion如果答案满分可以为空否则必须指出具体改进方向不超过80字
请按照以下JSON格式返回结果
{
"score": 85,
"is_correct": true,
"scoring_rationale": "依据《保密法》第XX条和标准答案要点分析...",
"feedback": "答案覆盖了主要要点但XXX部分描述不够准确",
"suggestion": "建议补充XXX内容并完善XXX的描述"
}
注意
1. 只返回JSON格式的结果不要有其他内容
2. 必须严格对照标准答案评分不要过于宽松
3. 评分依据必须说明符合或违反了哪些法规要求`, question, standardAnswer, userAnswer)
// 调用AI API
resp, err := s.client.CreateChatCompletion(
context.Background(),
openai.ChatCompletionRequest{
Model: s.config.Model,
Messages: []openai.ChatCompletionMessage{
{
Role: openai.ChatMessageRoleSystem,
Content: "你是一位专业的保密领域阅卷老师,精通保密法律法规。必须严格按照标准答案进行评分,不能过于宽松。你的评分标准是客观的、一致的、可预测的,并且必须基于保密法规进行专业分析。",
}, },
{ {
Role: openai.ChatMessageRoleUser, Role: openai.ChatMessageRoleUser,
@ -143,7 +255,7 @@ type AIExplanationResult struct {
// questionType: 题目类型 // questionType: 题目类型
func (s *AIGradingService) ExplainQuestionStream(writer http.ResponseWriter, question, standardAnswer, questionType string) error { func (s *AIGradingService) ExplainQuestionStream(writer http.ResponseWriter, question, standardAnswer, questionType string) error {
// 构建解析提示词直接输出不使用JSON格式 // 构建解析提示词直接输出不使用JSON格式
prompt := fmt.Sprintf(`你是一位严谨专业的老师请对以下题目进行详细解析 prompt := fmt.Sprintf(`你是一位严谨专业的保密领域专家老师请对以下题目进行详细解析
题目类型%s 题目类型%s
@ -151,30 +263,46 @@ func (s *AIGradingService) ExplainQuestionStream(writer http.ResponseWriter, que
标准答案%s 标准答案%s
**重要依据**请基于以下保密法律法规和管理制度进行专业分析
1. 中华人民共和国保守国家秘密法
2. 中华人民共和国保守国家秘密法实施条例
3. 保密工作管理制度2025.9.9
4. 软件开发管理制度
5. 涉密信息系统集成资质保密标准
6. 涉密信息系统集成资质管理办法
请提供一个详细的解析要求 请提供一个详细的解析要求
1. **必须实事求是**只基于题目内容和标准答案进行解析不要添加题目中没有的信息 1. **必须基于保密法规**解析时必须引用相关法规条文说明依据哪些具体法律法规
2. **不要胡编乱造**如果某些信息不确定或题目没有提供请如实说明不要编造 2. **必须实事求是**只基于题目内容标准答案和实际法规进行解析
3. **使用Markdown格式**使用标题列表加粗等markdown语法使内容更清晰易读 3. **不要胡编乱造**如果某些信息不确定或题目没有提供请如实说明不要编造法规条文
4. **使用Markdown格式**使用标题列表加粗等markdown语法使内容更清晰易读
解析内容要求 解析内容要求
- **知识点**说明题目考查的核心知识点 - **知识点**说明题目考查的核心知识点指出涉及哪些保密法规
- **解题思路**提供清晰的解题步骤和方法 - **法规依据**明确引用相关法律法规的具体条文保密法第X条保密法实施条例第X条等
- **解题思路**提供清晰的解题步骤和方法结合保密工作实际
%s %s
示例输出格式 示例输出格式
## 知识点 ## 知识点
题目考查的是... 本题考查的是[知识点名称]涉及XX法规第XX条...
## 法规依据
- 中华人民共和国保守国家秘密法第XX条规定...
- 保密工作管理制度2025.9.9第X章第X节...
## 解题思路 ## 解题思路
1. 首先分析... 1. 首先根据XX法规第XX条我们可以判断...
2. 然后判断... 2. 然后结合保密工作实际分析...
%s %s
## 总结 ## 总结
%s %s
**重要提醒**请务必在解析中引用具体的法规条文不要空泛地提及法规名称如果不确定具体条文编号可以说明法规的精神和要求
请使用markdown格式输出解析内容`, 请使用markdown格式输出解析内容`,
questionType, questionType,
question, question,
@ -182,32 +310,32 @@ func (s *AIGradingService) ExplainQuestionStream(writer http.ResponseWriter, que
// 根据题目类型添加特定要求 // 根据题目类型添加特定要求
func() string { func() string {
if questionType == "single-selection" || questionType == "multiple-selection" { if questionType == "single-selection" || questionType == "multiple-selection" {
return `- **选项分析**对于选择题必须逐项分析每个选项的对错及原因 return `- **选项分析**对于选择题必须逐项分析每个选项的对错及原因并说明依据哪些法规
- **记忆口诀**如果适用提供便于记忆的口诀或技巧` - **记忆口诀**如果适用提供便于记忆的口诀或技巧`
} }
return "- **答案解析**:详细说明为什么这个答案是正确的" return "- **答案解析**:详细说明为什么这个答案是正确的,并引用相关法规依据"
}(), }(),
// 根据题目类型添加示例格式 // 根据题目类型添加示例格式
func() string { func() string {
if questionType == "single-selection" || questionType == "multiple-selection" { if questionType == "single-selection" || questionType == "multiple-selection" {
return `## 选项分析 return `## 选项分析
- **A选项**[分析该选项] - **A选项**[分析该选项]根据XX法规第XX条...
- **B选项**[分析该选项] - **B选项**[分析该选项]根据XX法规第XX条...
- **C选项**[分析该选项] - **C选项**[分析该选项]根据XX法规第XX条...
- **D选项**[分析该选项] - **D选项**[分析该选项]根据XX法规第XX条...
## 正确答案 ## 正确答案
正确答案是... 因为...` 正确答案是... 因为根据XX法规第XX条规定...`
} }
return `## 答案解析 return `## 答案解析
正确答案是... 因为...` 正确答案是... 根据XX法规第XX条的规定...`
}(), }(),
// 根据题目类型添加总结要求 // 根据题目类型添加总结要求
func() string { func() string {
if questionType == "single-selection" || questionType == "multiple-selection" { if questionType == "single-selection" || questionType == "multiple-selection" {
return "对于选择题,可以提供记忆口诀或关键要点总结" return "对于选择题,可以提供记忆口诀或关键要点总结,并总结涉及的主要法规要求"
} }
return "总结本题的关键要点和注意事项" return "总结本题的关键要点、涉及的主要法规要求和在保密工作中的实际应用"
}(), }(),
) )
@ -219,14 +347,14 @@ func (s *AIGradingService) ExplainQuestionStream(writer http.ResponseWriter, que
Messages: []openai.ChatCompletionMessage{ Messages: []openai.ChatCompletionMessage{
{ {
Role: openai.ChatMessageRoleSystem, Role: openai.ChatMessageRoleSystem,
Content: "你是一位严谨、专业的老师,擅长深入浅出地讲解题目。你必须实事求是,只基于题目和标准答案提供解析不编造任何不确定的信息。你使用Markdown格式输出让学生更容易理解。", Content: "你是一位严谨、专业的保密领域专家老师,精通保密法律法规和管理制度。你擅长深入浅出地讲解题目,并能准确引用相关法规条文。你必须实事求是,只基于题目、标准答案和实际法规提供解析不编造任何不确定的信息。你使用Markdown格式输出让学生更容易理解。",
}, },
{ {
Role: openai.ChatMessageRoleUser, Role: openai.ChatMessageRoleUser,
Content: prompt, Content: prompt,
}, },
}, },
Temperature: 0, // 温度为0获得最确定、最一致的输出 Temperature: 0.3, // 低温度,保证输出相对稳定和专业
Stream: true, Stream: true,
}, },
) )

21
main.go
View File

@ -33,19 +33,24 @@ func main() {
api.POST("/login", handlers.Login) // 用户登录 api.POST("/login", handlers.Login) // 用户登录
api.POST("/register", handlers.Register) // 用户注册 api.POST("/register", handlers.Register) // 用户注册
// 练习题相关API // 公开的练习题相关API
api.GET("/practice/questions", handlers.GetPracticeQuestions) // 获取练习题目列表 api.GET("/practice/types", handlers.GetPracticeQuestionTypes) // 获取题型列表
api.GET("/practice/questions/random", handlers.GetRandomPracticeQuestion) // 获取随机练习题目
api.GET("/practice/questions/:id", handlers.GetPracticeQuestionByID) // 获取指定练习题目
api.GET("/practice/types", handlers.GetPracticeQuestionTypes) // 获取题型列表
api.POST("/practice/explain", handlers.ExplainQuestion) // 生成题目解析AI
// 需要认证的路由 // 需要认证的路由
auth := api.Group("", middleware.Auth()) auth := api.Group("", middleware.Auth())
{ {
// 用户相关API
auth.PUT("/user/type", handlers.UpdateUserType) // 更新用户类型
// 练习题相关API需要登录
auth.GET("/practice/questions", handlers.GetPracticeQuestions) // 获取练习题目列表
auth.GET("/practice/questions/random", handlers.GetRandomPracticeQuestion) // 获取随机练习题目
auth.GET("/practice/questions/:id", handlers.GetPracticeQuestionByID) // 获取指定练习题目
auth.POST("/practice/explain", handlers.ExplainQuestion) // 生成题目解析AI
// 练习题提交(需要登录才能记录错题) // 练习题提交(需要登录才能记录错题)
auth.POST("/practice/submit", handlers.SubmitPracticeAnswer) // 提交练习答案 auth.POST("/practice/submit", handlers.SubmitPracticeAnswer) // 提交练习答案
auth.GET("/practice/statistics", handlers.GetStatistics) // 获取统计数据 auth.GET("/practice/statistics", handlers.GetStatistics) // 获取统计数据
// 错题本相关API // 错题本相关API
auth.GET("/wrong-questions", handlers.GetWrongQuestions) // 获取错题列表 auth.GET("/wrong-questions", handlers.GetWrongQuestions) // 获取错题列表

View File

@ -2,6 +2,7 @@ import React, { useState } from 'react'
import { Alert, Typography, Card, Space, Progress, Button, Spin } from 'antd' import { Alert, Typography, Card, Space, Progress, Button, Spin } from 'antd'
import { CheckOutlined, CloseOutlined, TrophyOutlined, CommentOutlined, BulbOutlined, FileTextOutlined, ReloadOutlined } from '@ant-design/icons' import { CheckOutlined, CloseOutlined, TrophyOutlined, CommentOutlined, BulbOutlined, FileTextOutlined, ReloadOutlined } from '@ant-design/icons'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import { fetchWithAuth } from '../utils/request'
import type { AnswerResult as AnswerResultType } from '../types/question' import type { AnswerResult as AnswerResultType } from '../types/question'
const { Text, Paragraph } = Typography const { Text, Paragraph } = Typography
@ -31,11 +32,8 @@ const AnswerResult: React.FC<AnswerResultProps> = ({
try { try {
console.log('发送请求到 /api/practice/explain') console.log('发送请求到 /api/practice/explain')
const response = await fetch('/api/practice/explain', { const response = await fetchWithAuth('/api/practice/explain', {
method: 'POST', method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ question_id: questionId }), body: JSON.stringify({ question_id: questionId }),
}) })
@ -173,16 +171,19 @@ const AnswerResult: React.FC<AnswerResultProps> = ({
{formatAnswer(answerResult.user_answer || selectedAnswer)} {formatAnswer(answerResult.user_answer || selectedAnswer)}
</Text> </Text>
</div> </div>
<div style={{ marginBottom: 8 }}> {/* 论述题不显示正确答案,因为没有标准答案 */}
<Text strong style={{ color: '#52c41a' }}> {questionType !== 'ordinary-essay' && questionType !== 'management-essay' && (
<div style={{ marginBottom: 8 }}>
</Text> <Text strong style={{ color: '#52c41a' }}>
<Text strong style={{ color: '#52c41a' }}>
{formatAnswer( </Text>
answerResult.correct_answer || (answerResult.correct ? selectedAnswer : '暂无') <Text strong style={{ color: '#52c41a' }}>
)} {formatAnswer(
</Text> answerResult.correct_answer || (answerResult.correct ? selectedAnswer : '暂无')
</div> )}
</Text>
</div>
)}
{answerResult.explanation && ( {answerResult.explanation && (
<div> <div>
<Text type="secondary"></Text> <Text type="secondary"></Text>
@ -256,7 +257,7 @@ const AnswerResult: React.FC<AnswerResultProps> = ({
</Card> </Card>
)} )}
{/* AI评分结果 - 简答题显示 */} {/* AI评分结果 - 简答题和论述题显示 */}
{answerResult.ai_grading && ( {answerResult.ai_grading && (
<Card <Card
style={{ style={{
@ -299,6 +300,36 @@ const AnswerResult: React.FC<AnswerResultProps> = ({
</Space> </Space>
</div> </div>
{/* 参考答案(论述题) */}
{answerResult.ai_grading.reference_answer && (
<div style={{ marginBottom: 16, padding: 12, backgroundColor: '#f0f9ff', borderRadius: 4 }}>
<Space align="start">
<FileTextOutlined style={{ fontSize: 16, color: '#1890ff', marginTop: 2 }} />
<div style={{ flex: 1 }}>
<Text strong style={{ fontSize: 14, color: '#1890ff' }}></Text>
<Paragraph style={{ marginTop: 4, marginBottom: 0, color: '#262626', whiteSpace: 'pre-wrap' }}>
{answerResult.ai_grading.reference_answer}
</Paragraph>
</div>
</Space>
</div>
)}
{/* 评分依据 */}
{answerResult.ai_grading.scoring_rationale && (
<div style={{ marginBottom: 16, padding: 12, backgroundColor: '#f6ffed', borderRadius: 4 }}>
<Space align="start">
<CheckOutlined style={{ fontSize: 16, color: '#52c41a', marginTop: 2 }} />
<div style={{ flex: 1 }}>
<Text strong style={{ fontSize: 14, color: '#52c41a' }}></Text>
<Paragraph style={{ marginTop: 4, marginBottom: 0, color: '#262626', whiteSpace: 'pre-wrap' }}>
{answerResult.ai_grading.scoring_rationale}
</Paragraph>
</div>
</Space>
</div>
)}
{/* 评语 */} {/* 评语 */}
{answerResult.ai_grading.feedback && ( {answerResult.ai_grading.feedback && (
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>

View File

@ -88,14 +88,15 @@ const QuestionCard: React.FC<QuestionCardProps> = ({
return null return null
} }
if (question.type === 'short-answer') { // 简答题和论述题都显示文本框
if (question.type === 'short-answer' || question.type === 'ordinary-essay' || question.type === 'management-essay') {
return ( return (
<TextArea <TextArea
placeholder="请输入答案" placeholder="请输入答案"
value={selectedAnswer as string} value={selectedAnswer as string}
onChange={(e) => onAnswerChange(e.target.value)} onChange={(e) => onAnswerChange(e.target.value)}
disabled={showResult} disabled={showResult}
rows={4} rows={6}
style={{ marginTop: 20 }} style={{ marginTop: 20 }}
/> />
) )

View File

@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Card, Statistic, Row, Col, Typography, message, Space, Avatar, Button, Modal } from 'antd' import { Card, Statistic, Row, Col, Typography, message, Space, Avatar, Button, Modal, Form, Radio, Alert } from 'antd'
import { import {
FileTextOutlined, FileTextOutlined,
CheckCircleOutlined, CheckCircleOutlined,
@ -14,6 +14,7 @@ import {
UnorderedListOutlined as ListOutlined, UnorderedListOutlined as ListOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import * as questionApi from '../api/question' import * as questionApi from '../api/question'
import { fetchWithAuth } from '../utils/request'
import type { Statistics } from '../types/question' import type { Statistics } from '../types/question'
import styles from './Home.module.less' import styles from './Home.module.less'
@ -56,17 +57,28 @@ const questionTypes = [
color: '#eb2f96', color: '#eb2f96',
description: '深度理解练习', description: '深度理解练习',
}, },
{
key: 'essay', // 特殊标识,根据用户类型动态路由
title: '论述题',
icon: <FileTextOutlined />,
color: '#f759ab',
description: '深度分析与表达',
},
] ]
interface UserInfo { interface UserInfo {
username: string username: string
nickname: string nickname: string
avatar: string avatar: string
user_type?: string // 用户类型
} }
const Home: React.FC = () => { const Home: React.FC = () => {
const navigate = useNavigate() const navigate = useNavigate()
const [userInfo, setUserInfo] = useState<UserInfo | null>(null) const [userInfo, setUserInfo] = useState<UserInfo | null>(null)
const [userTypeModalVisible, setUserTypeModalVisible] = useState(false) // 用户类型补充模态框
const [loading, setLoading] = useState(false)
const [userTypeForm] = Form.useForm()
const [statistics, setStatistics] = useState<Statistics>({ const [statistics, setStatistics] = useState<Statistics>({
total_questions: 0, total_questions: 0,
answered_questions: 0, answered_questions: 0,
@ -94,7 +106,13 @@ const Home: React.FC = () => {
if (token && savedUserInfo) { if (token && savedUserInfo) {
try { try {
setUserInfo(JSON.parse(savedUserInfo)) const user = JSON.parse(savedUserInfo)
setUserInfo(user)
// 检查用户是否有用户类型,如果没有则显示强制选择模态框
if (!user.user_type) {
setUserTypeModalVisible(true)
}
} catch (e) { } catch (e) {
console.error('解析用户信息失败', e) console.error('解析用户信息失败', e)
} }
@ -105,14 +123,56 @@ const Home: React.FC = () => {
loadStatistics() loadStatistics()
}, []) }, [])
// 处理用户类型更新
const handleUpdateUserType = async (values: { user_type: string }) => {
setLoading(true)
try {
const response = await fetchWithAuth('/api/user/type', {
method: 'PUT',
body: JSON.stringify(values),
})
const data = await response.json()
if (data.success) {
// 更新本地存储的用户信息
const user = JSON.parse(localStorage.getItem('user') || '{}')
user.user_type = data.data.user_type
localStorage.setItem('user', JSON.stringify(user))
setUserInfo(user)
message.success('身份类型设置成功')
setUserTypeModalVisible(false)
} else {
message.error(data.message || '更新失败')
}
} catch (err) {
message.error('网络错误,请稍后重试')
console.error('更新用户类型错误:', err)
} finally {
setLoading(false)
}
}
// 点击题型卡片 // 点击题型卡片
const handleTypeClick = async (type: string) => { const handleTypeClick = async (type: string) => {
try { try {
// 如果是论述题,根据用户类型动态确定题型
let actualType = type
if (type === 'essay') {
if (!userInfo?.user_type) {
message.warning('请先设置您的身份类型')
return
}
// 根据用户类型选择对应的论述题
actualType = userInfo.user_type === 'ordinary-person' ? 'ordinary-essay' : 'management-essay'
}
// 加载该题型的题目列表 // 加载该题型的题目列表
const res = await questionApi.getQuestions({ type }) const res = await questionApi.getQuestions({ type: actualType })
if (res.success && res.data && res.data.length > 0) { if (res.success && res.data && res.data.length > 0) {
// 跳转到答题页面,并传递题型参数 // 跳转到答题页面,并传递题型参数
navigate(`/question?type=${type}`) navigate(`/question?type=${actualType}`)
} else { } else {
message.warning('该题型暂无题目') message.warning('该题型暂无题目')
} }
@ -316,6 +376,49 @@ const Home: React.FC = () => {
)} )}
</Row> </Row>
</div> </div>
{/* 用户类型补充模态框 */}
<Modal
title="请选择您的身份类型"
open={userTypeModalVisible}
closable={false}
maskClosable={false}
keyboard={false}
footer={null}
destroyOnClose
>
<Alert
message="论述题需要使用"
description="为了更好地为您提供相应的论述题内容,请选择您的身份类型。"
type="info"
showIcon
style={{ marginBottom: 24 }}
/>
<Form
form={userTypeForm}
name="userType"
onFinish={handleUpdateUserType}
autoComplete="off"
size="large"
>
<Form.Item
name="user_type"
label="身份类型"
rules={[{ required: true, message: '请选择身份类型' }]}
>
<Radio.Group>
<Radio value="ordinary-person"></Radio>
<Radio value="management-person"></Radio>
</Radio.Group>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block loading={loading}>
</Button>
</Form.Item>
</Form>
</Modal>
</div> </div>
) )
} }

View File

@ -1,7 +1,8 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Form, Input, Button, Card, Modal, message, Typography } from 'antd' import { Form, Input, Button, Card, Modal, message, Typography, Radio, Alert } from 'antd'
import { UserOutlined, LockOutlined, IdcardOutlined } from '@ant-design/icons' import { UserOutlined, LockOutlined, IdcardOutlined } from '@ant-design/icons'
import { fetchWithAuth } from '../utils/request'
import styles from './Login.module.less' import styles from './Login.module.less'
const { Title, Text, Link } = Typography const { Title, Text, Link } = Typography
@ -9,12 +10,14 @@ const { Title, Text, Link } = Typography
interface LoginResponse { interface LoginResponse {
success: boolean success: boolean
message: string message: string
need_user_type?: boolean // 是否需要补充用户类型
data?: { data?: {
token: string token: string
user: { user: {
username: string username: string
avatar: string avatar: string
nickname: string nickname: string
user_type?: string // 用户类型
} }
} }
} }
@ -23,8 +26,11 @@ const Login: React.FC = () => {
const navigate = useNavigate() const navigate = useNavigate()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [registerModalVisible, setRegisterModalVisible] = useState(false) const [registerModalVisible, setRegisterModalVisible] = useState(false)
const [userTypeModalVisible, setUserTypeModalVisible] = useState(false) // 用户类型补充模态框
const [userType, setUserType] = useState<string>('') // 临时存储用户选择的类型
const [loginForm] = Form.useForm() const [loginForm] = Form.useForm()
const [registerForm] = Form.useForm() const [registerForm] = Form.useForm()
const [userTypeForm] = Form.useForm()
// 如果已登录,重定向到首页 // 如果已登录,重定向到首页
useEffect(() => { useEffect(() => {
@ -38,11 +44,8 @@ const Login: React.FC = () => {
const handleLogin = async (values: { username: string; password: string }) => { const handleLogin = async (values: { username: string; password: string }) => {
setLoading(true) setLoading(true)
try { try {
const response = await fetch('/api/login', { const response = await fetchWithAuth('/api/login', {
method: 'POST', method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(values), body: JSON.stringify(values),
}) })
@ -52,8 +55,13 @@ const Login: React.FC = () => {
localStorage.setItem('token', data.data.token) localStorage.setItem('token', data.data.token)
localStorage.setItem('user', JSON.stringify(data.data.user)) localStorage.setItem('user', JSON.stringify(data.data.user))
message.success('登录成功') // 检查是否需要补充用户类型
navigate('/') if (data.need_user_type) {
setUserTypeModalVisible(true)
} else {
message.success('登录成功')
navigate('/')
}
} else { } else {
message.error(data.message || '登录失败') message.error(data.message || '登录失败')
} }
@ -66,14 +74,11 @@ const Login: React.FC = () => {
} }
// 处理注册 // 处理注册
const handleRegister = async (values: { username: string; password: string; nickname?: string }) => { const handleRegister = async (values: { username: string; password: string; nickname?: string; user_type: string }) => {
setLoading(true) setLoading(true)
try { try {
const response = await fetch('/api/register', { const response = await fetchWithAuth('/api/register', {
method: 'POST', method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(values), body: JSON.stringify(values),
}) })
@ -97,6 +102,37 @@ const Login: React.FC = () => {
} }
} }
// 处理用户类型更新
const handleUpdateUserType = async (values: { user_type: string }) => {
setLoading(true)
try {
const response = await fetchWithAuth('/api/user/type', {
method: 'PUT',
body: JSON.stringify(values),
})
const data = await response.json()
if (data.success) {
// 更新本地存储的用户信息
const user = JSON.parse(localStorage.getItem('user') || '{}')
user.user_type = data.data.user_type
localStorage.setItem('user', JSON.stringify(user))
message.success('身份类型设置成功')
setUserTypeModalVisible(false)
navigate('/')
} else {
message.error(data.message || '更新失败')
}
} catch (err) {
message.error('网络错误,请稍后重试')
console.error('更新用户类型错误:', err)
} finally {
setLoading(false)
}
}
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.content}> <div className={styles.content}>
@ -205,6 +241,17 @@ const Login: React.FC = () => {
/> />
</Form.Item> </Form.Item>
<Form.Item
name="user_type"
label="身份类型"
rules={[{ required: true, message: '请选择身份类型' }]}
>
<Radio.Group>
<Radio value="ordinary-person"></Radio>
<Radio value="management-person"></Radio>
</Radio.Group>
</Form.Item>
<Form.Item> <Form.Item>
<Button type="primary" htmlType="submit" block loading={loading}> <Button type="primary" htmlType="submit" block loading={loading}>
@ -212,6 +259,48 @@ const Login: React.FC = () => {
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Modal>
{/* 用户类型补充模态框 */}
<Modal
title="请选择您的身份类型"
open={userTypeModalVisible}
closable={false}
maskClosable={false}
footer={null}
destroyOnClose
>
<Alert
message="论述题需要使用"
description="为了更好地为您提供相应的论述题内容,请选择您的身份类型。"
type="info"
showIcon
style={{ marginBottom: 24 }}
/>
<Form
form={userTypeForm}
name="userType"
onFinish={handleUpdateUserType}
autoComplete="off"
size="large"
>
<Form.Item
name="user_type"
label="身份类型"
rules={[{ required: true, message: '请选择身份类型' }]}
>
<Radio.Group>
<Radio value="ordinary-person"></Radio>
<Radio value="management-person"></Radio>
</Radio.Group>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block loading={loading}>
</Button>
</Form.Item>
</Form>
</Modal>
</div> </div>
) )
} }

View File

@ -35,6 +35,8 @@ const questionTypeConfig: Record<string, { label: string; icon: React.ReactNode;
'true-false': { label: '判断题', icon: <CheckCircleOutlined />, color: '#fa8c16' }, 'true-false': { label: '判断题', icon: <CheckCircleOutlined />, color: '#fa8c16' },
'fill-in-blank': { label: '填空题', icon: <FileTextOutlined />, color: '#722ed1' }, 'fill-in-blank': { label: '填空题', icon: <FileTextOutlined />, color: '#722ed1' },
'short-answer': { label: '简答题', icon: <EditOutlined />, color: '#eb2f96' }, 'short-answer': { label: '简答题', icon: <EditOutlined />, color: '#eb2f96' },
'ordinary-essay': { label: '普通涉密人员论述题', icon: <FileTextOutlined />, color: '#f759ab' },
'management-essay': { label: '保密管理人员论述题', icon: <FileTextOutlined />, color: '#d4380d' },
} }
const QuestionList: React.FC = () => { const QuestionList: React.FC = () => {

View File

@ -36,6 +36,8 @@ const questionTypes = [
{ key: 'true-false', label: '判断题' }, { key: 'true-false', label: '判断题' },
{ key: 'fill-in-blank', label: '填空题' }, { key: 'fill-in-blank', label: '填空题' },
{ key: 'short-answer', label: '简答题' }, { key: 'short-answer', label: '简答题' },
{ key: 'ordinary-essay', label: '普通涉密人员论述题' },
{ key: 'management-essay', label: '保密管理人员论述题' },
] ]
const QuestionManagement: React.FC = () => { const QuestionManagement: React.FC = () => {
@ -137,7 +139,7 @@ const QuestionManagement: React.FC = () => {
} else if (values.type === 'fill-in-blank') { } else if (values.type === 'fill-in-blank') {
// 填空题答案是数组 // 填空题答案是数组
answer = values.answer answer = values.answer
} else if (values.type === 'short-answer') { } else if (values.type === 'short-answer' || values.type === 'ordinary-essay' || values.type === 'management-essay') {
answer = values.answer answer = values.answer
} else { } else {
answer = values.answer answer = values.answer
@ -205,7 +207,7 @@ const QuestionManagement: React.FC = () => {
title: '题型', title: '题型',
dataIndex: 'type', dataIndex: 'type',
key: 'type', key: 'type',
width: 120, width: 180,
render: (type: string) => { render: (type: string) => {
const typeConfig = questionTypes.find(t => t.key === type) const typeConfig = questionTypes.find(t => t.key === type)
const colorMap: Record<string, string> = { const colorMap: Record<string, string> = {
@ -214,6 +216,8 @@ const QuestionManagement: React.FC = () => {
'true-false': 'orange', 'true-false': 'orange',
'fill-in-blank': 'purple', 'fill-in-blank': 'purple',
'short-answer': 'magenta', 'short-answer': 'magenta',
'ordinary-essay': 'pink',
'management-essay': 'red',
} }
return <Tag color={colorMap[type]}>{typeConfig?.label || type}</Tag> return <Tag color={colorMap[type]}>{typeConfig?.label || type}</Tag>
}, },
@ -434,6 +438,8 @@ const QuestionManagement: React.FC = () => {
) )
case 'short-answer': case 'short-answer':
case 'ordinary-essay':
case 'management-essay':
return ( return (
<Form.Item <Form.Item
label="参考答案" label="参考答案"

View File

@ -1,5 +1,5 @@
// 题目类型 - 使用数据库中的实际类型 // 题目类型 - 使用数据库中的实际类型
export type QuestionType = 'multiple-choice' | 'multiple-selection' | 'fill-in-blank' | 'true-false' | 'short-answer' export type QuestionType = 'multiple-choice' | 'multiple-selection' | 'fill-in-blank' | 'true-false' | 'short-answer' | 'ordinary-essay' | 'management-essay'
// 选项 // 选项
export interface Option { export interface Option {
@ -29,6 +29,8 @@ export interface AIGrading {
score: number // 得分 (0-100) score: number // 得分 (0-100)
feedback: string // 评语 feedback: string // 评语
suggestion: string // 改进建议 suggestion: string // 改进建议
reference_answer?: string // 参考答案(论述题)
scoring_rationale?: string // 评分依据
} }
// 答案结果 // 答案结果
@ -37,7 +39,7 @@ export interface AnswerResult {
user_answer: string | string[] | boolean user_answer: string | string[] | boolean
correct_answer: string | string[] correct_answer: string | string[]
explanation?: string explanation?: string
ai_grading?: AIGrading // AI评分结果简答题) ai_grading?: AIGrading // AI评分结果简答题和论述题)
} }
// 统计数据 // 统计数据

View File

@ -76,4 +76,52 @@ export const request = {
}, },
} }
// 统一的 fetch 请求工具(用于需要原生 fetch 的场景,如流式请求)
interface FetchOptions extends RequestInit {
// 扩展选项(如果需要)
}
/**
* fetch
* Authorization header
*/
export const fetchWithAuth = async (
url: string,
options: FetchOptions = {}
): Promise<Response> => {
// 获取 token
const token = localStorage.getItem('token')
// 合并 headers
const headers: HeadersInit = {
'Content-Type': 'application/json',
...options.headers,
}
// 如果有 token添加到请求头
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
// 构建完整的请求配置
const fetchOptions: RequestInit = {
...options,
headers,
}
// 发送请求
const response = await fetch(url, fetchOptions)
// 统一处理 401 未授权错误
if (response.status === 401) {
console.error('Token已过期或未授权请重新登录')
localStorage.removeItem('token')
localStorage.removeItem('user')
window.location.href = '/login'
throw new Error('未授权,请重新登录')
}
return response
}
export default instance export default instance