feat: 实现模拟考试功能

## 后端实现
- 添加考试数据模型 (Exam)
- 实现考试生成API (/api/exam/generate)
- 实现获取考试详情API (/api/exam/:id)
- 实现提交考试API (/api/exam/:id/submit)
- 支持按题型随机抽取题目
- AI智能评分(简答题和论述题)
- 自动计算总分和详细评分

## 前端实现
- 首页添加"模拟考试"入口
- 考试准备页:显示考试说明,选择在线/打印模式
- 在线答题页:按题型分组显示,支持论述题二选一
- 试卷打印页:A4排版,支持打印试卷/答案
- 成绩报告页:显示总分、详细评分、错题分析

## 核心特性
- 随机组卷:填空10题、判断10题、单选10题、多选10题、简答2题、论述题2选1
- 智能评分:使用AI评分论述题,给出分数、评语和建议
- 答题进度保存:使用localStorage防止刷新丢失
- 打印优化:A4纸张、黑白打印、合理排版
- 响应式设计:适配移动端、平板和PC端

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
燕陇琪 2025-11-08 20:45:30 +08:00
parent 77bd709613
commit 52fff11f07
16 changed files with 3853 additions and 0 deletions

View File

@ -37,6 +37,7 @@ func InitDB() error {
&models.WrongQuestionHistory{}, // 错题历史表
&models.WrongQuestionTag{}, // 错题标签表
&models.UserAnswerRecord{}, // 用户答题记录表
&models.Exam{}, // 考试表
)
if err != nil {
return fmt.Errorf("failed to migrate database: %w", err)

View File

@ -0,0 +1,421 @@
package handlers
import (
"ankao/internal/database"
"ankao/internal/models"
"ankao/internal/services"
"encoding/json"
"fmt"
"log"
"math/rand"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
// GenerateExam 生成考试
func GenerateExam(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"})
return
}
db := database.GetDB()
// 使用默认配置
config := models.DefaultExamConfig
// 按题型随机抽取题目
questionsByType := make(map[string][]models.PracticeQuestion)
questionTypes := []struct {
Type string
Count int
}{
{"fill-in-blank", config.FillInBlank},
{"true-false", config.TrueFalse},
{"multiple-choice", config.MultipleChoice},
{"multiple-selection", config.MultipleSelection},
{"short-answer", config.ShortAnswer},
{"ordinary-essay", config.OrdinaryEssay},
{"management-essay", config.ManagementEssay},
}
var allQuestionIDs []uint
for _, qt := range questionTypes {
var questions []models.PracticeQuestion
if err := db.Where("type = ?", qt.Type).Find(&questions).Error; err != nil {
log.Printf("查询题目失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询题目失败"})
return
}
// 检查题目数量是否足够
if len(questions) < qt.Count {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": fmt.Sprintf("题型 %s 题目数量不足,需要 %d 道,实际 %d 道", qt.Type, qt.Count, len(questions)),
})
return
}
// 随机抽取 (Fisher-Yates 洗牌算法)
rand.Seed(time.Now().UnixNano())
for i := len(questions) - 1; i > 0; i-- {
j := rand.Intn(i + 1)
questions[i], questions[j] = questions[j], questions[i]
}
selectedQuestions := questions[:qt.Count]
questionsByType[qt.Type] = selectedQuestions
// 收集题目ID
for _, q := range selectedQuestions {
allQuestionIDs = append(allQuestionIDs, q.ID)
}
}
// 将题目ID列表转为JSON
questionIDsJSON, err := json.Marshal(allQuestionIDs)
if err != nil {
log.Printf("序列化题目ID失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "生成考试失败"})
return
}
// 创建考试记录
exam := models.Exam{
UserID: userID.(uint),
QuestionIDs: string(questionIDsJSON),
Status: "draft",
}
if err := db.Create(&exam).Error; err != nil {
log.Printf("创建考试记录失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "创建考试失败"})
return
}
// 返回考试信息
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"exam_id": exam.ID,
"question_ids": allQuestionIDs,
"created_at": exam.CreatedAt,
},
})
}
// GetExam 获取考试详情
func GetExam(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"})
return
}
examIDStr := c.Param("id")
examID, err := strconv.ParseUint(examIDStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的考试ID"})
return
}
db := database.GetDB()
// 查询考试记录
var exam models.Exam
if err := db.Where("id = ? AND user_id = ?", examID, userID).First(&exam).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "考试不存在"})
return
}
// 解析题目ID列表
var questionIDs []uint
if err := json.Unmarshal([]byte(exam.QuestionIDs), &questionIDs); err != nil {
log.Printf("解析题目ID失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "解析考试数据失败"})
return
}
// 查询题目详情
var questions []models.PracticeQuestion
if err := db.Where("id IN ?", questionIDs).Find(&questions).Error; err != nil {
log.Printf("查询题目失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询题目失败"})
return
}
// 按原始顺序排序题目
questionMap := make(map[uint]models.PracticeQuestion)
for _, q := range questions {
questionMap[q.ID] = q
}
orderedQuestions := make([]models.PracticeQuestion, 0, len(questionIDs))
for _, id := range questionIDs {
if q, ok := questionMap[id]; ok {
orderedQuestions = append(orderedQuestions, q)
}
}
// 是否显示答案
showAnswer := c.Query("show_answer") == "true"
if !showAnswer {
// 不显示答案时,隐藏答案字段
for i := range orderedQuestions {
orderedQuestions[i].AnswerData = ""
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"exam": exam,
"questions": orderedQuestions,
},
})
}
// SubmitExam 提交考试
func SubmitExam(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"})
return
}
examIDStr := c.Param("id")
examID, err := strconv.ParseUint(examIDStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的考试ID"})
return
}
// 解析请求体
var req struct {
Answers map[string]interface{} `json:"answers"` // question_id -> answer
EssayChoice string `json:"essay_choice"` // 论述题选择: ordinary 或 management
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的请求数据"})
return
}
db := database.GetDB()
// 查询考试记录
var exam models.Exam
if err := db.Where("id = ? AND user_id = ?", examID, userID).First(&exam).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "考试不存在"})
return
}
// 检查是否已提交
if exam.Status == "submitted" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "考试已提交"})
return
}
// 解析题目ID列表
var questionIDs []uint
if err := json.Unmarshal([]byte(exam.QuestionIDs), &questionIDs); err != nil {
log.Printf("解析题目ID失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "解析考试数据失败"})
return
}
// 查询题目详情
var questions []models.PracticeQuestion
if err := db.Where("id IN ?", questionIDs).Find(&questions).Error; err != nil {
log.Printf("查询题目失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询题目失败"})
return
}
// 评分
totalScore := 0.0
scoreConfig := models.DefaultScoreConfig
aiService := services.NewAIGradingService()
detailedResults := make(map[string]interface{})
for _, question := range questions {
questionIDStr := fmt.Sprintf("%d", question.ID)
userAnswerRaw, answered := req.Answers[questionIDStr]
if !answered {
detailedResults[questionIDStr] = gin.H{
"correct": false,
"score": 0,
"message": "未作答",
}
continue
}
// 论述题特殊处理:根据用户选择判断是否计分
if question.Type == "ordinary-essay" && req.EssayChoice != "ordinary" {
detailedResults[questionIDStr] = gin.H{
"correct": false,
"score": 0,
"message": "未选择此题",
}
continue
}
if question.Type == "management-essay" && req.EssayChoice != "management" {
detailedResults[questionIDStr] = gin.H{
"correct": false,
"score": 0,
"message": "未选择此题",
}
continue
}
// 根据题型判断答案
var isCorrect bool
var score float64
var aiGrading *models.AIGrading
switch question.Type {
case "fill-in-blank":
// 填空题:比较数组
userAnswerArr, ok := userAnswerRaw.([]interface{})
if !ok {
detailedResults[questionIDStr] = gin.H{"correct": false, "score": 0, "message": "答案格式错误"}
continue
}
var correctAnswers []string
if err := json.Unmarshal([]byte(question.AnswerData), &correctAnswers); err != nil {
log.Printf("解析填空题答案失败: %v", err)
continue
}
isCorrect = true
for i, ua := range userAnswerArr {
if i >= len(correctAnswers) || fmt.Sprintf("%v", ua) != correctAnswers[i] {
isCorrect = false
break
}
}
if isCorrect {
score = scoreConfig.FillInBlank
}
case "true-false":
// 判断题
var correctAnswer string
if err := json.Unmarshal([]byte(question.AnswerData), &correctAnswer); err != nil {
log.Printf("解析判断题答案失败: %v", err)
continue
}
isCorrect = fmt.Sprintf("%v", userAnswerRaw) == correctAnswer
if isCorrect {
score = scoreConfig.TrueFalse
}
case "multiple-choice":
// 单选题
var correctAnswer string
if err := json.Unmarshal([]byte(question.AnswerData), &correctAnswer); err != nil {
log.Printf("解析单选题答案失败: %v", err)
continue
}
isCorrect = fmt.Sprintf("%v", userAnswerRaw) == correctAnswer
if isCorrect {
score = scoreConfig.MultipleChoice
}
case "multiple-selection":
// 多选题:比较数组(顺序无关)
userAnswerArr, ok := userAnswerRaw.([]interface{})
if !ok {
detailedResults[questionIDStr] = gin.H{"correct": false, "score": 0, "message": "答案格式错误"}
continue
}
var correctAnswers []string
if err := json.Unmarshal([]byte(question.AnswerData), &correctAnswers); err != nil {
log.Printf("解析多选题答案失败: %v", err)
continue
}
userAnswerSet := make(map[string]bool)
for _, ua := range userAnswerArr {
userAnswerSet[fmt.Sprintf("%v", ua)] = true
}
isCorrect = len(userAnswerSet) == len(correctAnswers)
if isCorrect {
for _, ca := range correctAnswers {
if !userAnswerSet[ca] {
isCorrect = false
break
}
}
}
if isCorrect {
score = scoreConfig.MultipleSelection
}
case "short-answer", "ordinary-essay", "management-essay":
// 简答题和论述题使用AI评分
var correctAnswer string
if err := json.Unmarshal([]byte(question.AnswerData), &correctAnswer); err != nil {
log.Printf("解析简答题答案失败: %v", err)
continue
}
userAnswerStr := fmt.Sprintf("%v", userAnswerRaw)
aiResult, err := aiService.GradeShortAnswer(question.Question, correctAnswer, userAnswerStr)
if err != nil {
log.Printf("AI评分失败: %v", err)
// AI评分失败时给一个保守的分数
isCorrect = false
score = 0
} else {
isCorrect = aiResult.IsCorrect
if question.Type == "short-answer" {
// 简答题不计分,仅供参考
score = 0
} else {
// 论述题按AI评分比例计算
score = scoreConfig.Essay * (aiResult.Score / 100.0)
}
aiGrading = &models.AIGrading{
Score: aiResult.Score,
Feedback: aiResult.Feedback,
Suggestion: aiResult.Suggestion,
}
}
}
totalScore += score
detailedResults[questionIDStr] = gin.H{
"correct": isCorrect,
"score": score,
"ai_grading": aiGrading,
}
}
// 保存答案和分数
answersJSON, err := json.Marshal(req.Answers)
if err != nil {
log.Printf("序列化答案失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "保存答案失败"})
return
}
now := time.Now()
exam.Answers = string(answersJSON)
exam.Score = totalScore
exam.Status = "submitted"
exam.SubmittedAt = &now
if err := db.Save(&exam).Error; err != nil {
log.Printf("保存考试记录失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "保存考试失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"score": totalScore,
"detailed_results": detailedResults,
},
})
}

62
internal/models/exam.go Normal file
View File

@ -0,0 +1,62 @@
package models
import (
"time"
"gorm.io/gorm"
)
// Exam 考试记录
type Exam struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
UserID uint `json:"user_id"` // 用户ID
QuestionIDs string `gorm:"type:text" json:"question_ids"` // 题目ID列表(JSON数组)
Answers string `gorm:"type:text" json:"answers"` // 用户答案(JSON对象)
Score float64 `json:"score"` // 总分
Status string `gorm:"default:'draft'" json:"status"` // 状态: draft/submitted
SubmittedAt *time.Time `json:"submitted_at"` // 提交时间
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
}
// ExamQuestionConfig 考试题目配置
type ExamQuestionConfig struct {
FillInBlank int `json:"fill_in_blank"` // 填空题数量
TrueFalse int `json:"true_false"` // 判断题数量
MultipleChoice int `json:"multiple_choice"` // 单选题数量
MultipleSelection int `json:"multiple_selection"` // 多选题数量
ShortAnswer int `json:"short_answer"` // 简答题数量
OrdinaryEssay int `json:"ordinary_essay"` // 普通涉密人员论述题数量
ManagementEssay int `json:"management_essay"` // 保密管理人员论述题数量
}
// DefaultExamConfig 默认考试配置
var DefaultExamConfig = ExamQuestionConfig{
FillInBlank: 10, // 填空题10道
TrueFalse: 10, // 判断题10道
MultipleChoice: 10, // 单选题10道
MultipleSelection: 10, // 多选题10道
ShortAnswer: 2, // 简答题2道
OrdinaryEssay: 1, // 普通论述题1道
ManagementEssay: 1, // 管理论述题1道
}
// ExamScoreConfig 考试分值配置
type ExamScoreConfig struct {
FillInBlank float64 `json:"fill_in_blank"` // 填空题分值
TrueFalse float64 `json:"true_false"` // 判断题分值
MultipleChoice float64 `json:"multiple_choice"` // 单选题分值
MultipleSelection float64 `json:"multiple_selection"` // 多选题分值
Essay float64 `json:"essay"` // 论述题分值
}
// DefaultScoreConfig 默认分值配置
var DefaultScoreConfig = ExamScoreConfig{
FillInBlank: 2.0, // 填空题每题2分 (共20分)
TrueFalse: 2.0, // 判断题每题2分 (共20分)
MultipleChoice: 1.0, // 单选题每题1分 (共10分)
MultipleSelection: 2.5, // 多选题每题2.5分 (共25分)
Essay: 25.0, // 论述题25分
}

View File

@ -70,6 +70,11 @@ func main() {
auth.POST("/wrong-question-tags", handlers.CreateWrongQuestionTag) // 创建标签
auth.PUT("/wrong-question-tags/:id", handlers.UpdateWrongQuestionTag) // 更新标签
auth.DELETE("/wrong-question-tags/:id", handlers.DeleteWrongQuestionTag) // 删除标签
// 考试相关API
auth.POST("/exam/generate", handlers.GenerateExam) // 生成考试
auth.GET("/exam/:id", handlers.GetExam) // 获取考试详情
auth.POST("/exam/:id/submit", handlers.SubmitExam) // 提交考试
}
// 题库管理API需要管理员权限

View File

@ -14,6 +14,10 @@ import QuestionManagement from './pages/QuestionManagement'
import QuestionList from './pages/QuestionList'
import UserManagement from './pages/UserManagement'
import UserDetail from './pages/UserDetail'
import ExamPrepare from './pages/ExamPrepare'
import ExamOnline from './pages/ExamOnline'
import ExamPrint from './pages/ExamPrint'
import ExamResult from './pages/ExamResult'
const App: React.FC = () => {
return (
@ -30,6 +34,12 @@ const App: React.FC = () => {
<Route path="/wrong-questions" element={<ProtectedRoute><WrongQuestions /></ProtectedRoute>} />
<Route path="/question-list" element={<ProtectedRoute><QuestionList /></ProtectedRoute>} />
{/* 考试相关页面,需要登录保护 */}
<Route path="/exam/new" element={<ProtectedRoute><ExamPrepare /></ProtectedRoute>} />
<Route path="/exam/:examId/online" element={<ProtectedRoute><ExamOnline /></ProtectedRoute>} />
<Route path="/exam/:examId/print" element={<ProtectedRoute><ExamPrint /></ProtectedRoute>} />
<Route path="/exam/:examId/result" element={<ProtectedRoute><ExamResult /></ProtectedRoute>} />
{/* 题库管理页面,需要管理员权限 */}
<Route path="/question-management" element={
<ProtectedRoute>

25
web/src/api/exam.ts Normal file
View File

@ -0,0 +1,25 @@
import { request } from '../utils/request'
import type {
GenerateExamResponse,
GetExamResponse,
SubmitExamRequest,
SubmitExamResponse
} from '../types/exam'
import type { ApiResponse } from '../types/question'
// 生成考试
export const generateExam = () => {
return request.post<ApiResponse<GenerateExamResponse>>('/exam/generate')
}
// 获取考试详情
export const getExam = (examId: number, showAnswer?: boolean) => {
return request.get<ApiResponse<GetExamResponse>>(`/exam/${examId}`, {
params: { show_answer: showAnswer },
})
}
// 提交考试
export const submitExam = (examId: number, data: SubmitExamRequest) => {
return request.post<ApiResponse<SubmitExamResponse>>(`/exam/${examId}/submit`, data)
}

View File

@ -0,0 +1,391 @@
.container {
min-height: 100vh;
background: #fafafa;
padding: 0;
padding-bottom: 80px;
}
// 固定顶栏
.fixedTopBar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
background: rgba(250, 250, 250, 0.85);
backdrop-filter: blur(40px) saturate(180%);
-webkit-backdrop-filter: blur(40px) saturate(180%);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.topBarContent {
max-width: 1000px;
margin: 0 auto;
padding: 16px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.backButton {
color: #007aff;
font-weight: 500;
padding: 4px 12px;
&:hover {
color: #0051d5;
background: rgba(0, 122, 255, 0.08);
}
}
.title {
color: #1d1d1f !important;
margin: 0 !important;
font-weight: 700;
font-size: 18px !important;
flex: 1;
text-align: center;
}
.content {
max-width: 1000px;
margin: 0 auto;
padding: 0 20px;
padding-top: 80px;
}
// 加载状态
.loadingContainer {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #fafafa;
}
// 考试说明卡片
.examInfoCard {
margin-bottom: 24px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
:global(.ant-card-body) {
padding: 24px;
}
h4 {
margin-bottom: 16px;
color: #1d1d1f;
font-weight: 600;
}
ul {
margin: 0;
padding-left: 20px;
color: #6e6e73;
line-height: 1.8;
li {
margin-bottom: 8px;
}
}
}
// 题目组
.questionGroup {
margin-bottom: 32px;
:global(.ant-divider) {
margin: 24px 0;
border-color: #e5e5ea;
}
h3 {
color: #1d1d1f;
font-weight: 600;
font-size: 20px;
margin: 0;
}
}
// 题目卡片
.questionCard {
margin-bottom: 16px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
:global(.ant-card-body) {
padding: 24px;
}
}
.questionHeader {
margin-bottom: 16px;
display: flex;
align-items: center;
flex-wrap: wrap;
span {
font-size: 16px;
line-height: 1.6;
color: #1d1d1f;
}
}
// 论述题选择区域
.essaySection {
.essayChoiceCard {
margin-bottom: 24px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
:global(.ant-card-body) {
padding: 24px;
}
h4 {
margin-bottom: 8px;
color: #1d1d1f;
font-weight: 600;
}
:global(.ant-typography) {
color: #6e6e73;
}
}
.essayRadioGroup {
width: 100%;
margin-top: 16px;
:global(.ant-space) {
width: 100%;
}
}
.essayOptionCard {
border-radius: 12px;
cursor: pointer;
transition: all 0.3s ease;
border: 2px solid transparent;
&:hover {
border-color: #007aff;
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.15);
}
&.selected {
border-color: #007aff;
background: rgba(0, 122, 255, 0.05);
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.15);
}
:global(.ant-card-body) {
padding: 20px;
}
:global(.ant-radio) {
align-items: flex-start;
}
.essayContent {
margin-top: 12px;
margin-left: 24px;
:global(.ant-typography) {
color: #1d1d1f;
font-size: 15px;
line-height: 1.6;
margin-bottom: 0;
}
}
}
}
// 提交按钮区域
.submitSection {
margin-top: 48px;
margin-bottom: 48px;
display: flex;
justify-content: center;
padding: 24px 0;
}
.submitButton {
min-width: 200px;
height: 48px;
font-size: 16px;
font-weight: 600;
border-radius: 24px;
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.3);
&:hover {
box-shadow: 0 6px 16px rgba(0, 122, 255, 0.4);
transform: translateY(-2px);
}
}
// 表单项样式
:global {
.ant-form-item {
margin-bottom: 16px;
}
.ant-input,
.ant-input-textarea {
border-radius: 8px;
font-size: 15px;
&:focus,
&:hover {
border-color: #007aff;
box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.1);
}
}
.ant-radio-wrapper,
.ant-checkbox-wrapper {
font-size: 15px;
line-height: 1.8;
color: #1d1d1f;
}
.ant-radio-checked .ant-radio-inner,
.ant-checkbox-checked .ant-checkbox-inner {
background-color: #007aff;
border-color: #007aff;
}
}
// 响应式设计 - 移动端
@media (max-width: 768px) {
.container {
padding-bottom: 70px;
}
.topBarContent {
padding: 12px;
}
.title {
font-size: 16px !important;
}
.content {
padding: 0 12px;
padding-top: 70px;
}
.examInfoCard,
.questionCard {
border-radius: 8px;
:global(.ant-card-body) {
padding: 16px;
}
}
.questionHeader span {
font-size: 14px;
}
.questionGroup {
margin-bottom: 24px;
h3 {
font-size: 18px;
}
}
.essaySection {
.essayChoiceCard {
:global(.ant-card-body) {
padding: 16px;
}
h4 {
font-size: 16px;
}
}
.essayOptionCard {
:global(.ant-card-body) {
padding: 16px;
}
.essayContent {
margin-left: 20px;
:global(.ant-typography) {
font-size: 14px;
}
}
}
}
.submitSection {
margin-top: 32px;
margin-bottom: 32px;
}
.submitButton {
width: 100%;
height: 44px;
font-size: 15px;
}
}
// 响应式设计 - 平板
@media (min-width: 769px) and (max-width: 1024px) {
.topBarContent {
padding: 14px 24px;
}
.content {
padding: 0 24px;
padding-top: 75px;
}
.title {
font-size: 20px !important;
}
.questionCard {
:global(.ant-card-body) {
padding: 20px;
}
}
}
// 响应式设计 - PC端
@media (min-width: 1025px) {
.topBarContent {
padding: 18px 32px;
}
.content {
padding: 0 32px;
padding-top: 85px;
}
.title {
font-size: 22px !important;
}
.questionCard {
:global(.ant-card-body) {
padding: 28px;
}
}
.questionHeader span {
font-size: 17px;
}
}

View File

@ -0,0 +1,543 @@
import React, { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import {
Card,
Form,
Input,
Radio,
Checkbox,
Button,
Typography,
message,
Spin,
Space,
Divider,
Modal,
} from 'antd'
import {
ArrowLeftOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
} from '@ant-design/icons'
import * as examApi from '../api/exam'
import type { Question } from '../types/question'
import type { GetExamResponse, SubmitExamRequest } from '../types/exam'
import styles from './ExamOnline.module.less'
const { Title, Paragraph, Text } = Typography
const { TextArea } = Input
// 题型顺序映射
const TYPE_ORDER: Record<string, number> = {
'fill-in-blank': 1,
'true-false': 2,
'multiple-choice': 3,
'multiple-selection': 4,
'short-answer': 5,
'ordinary-essay': 6,
'management-essay': 6,
}
// 题型名称映射
const TYPE_NAME: Record<string, string> = {
'fill-in-blank': '填空题',
'true-false': '判断题',
'multiple-choice': '单选题',
'multiple-selection': '多选题',
'short-answer': '简答题',
'ordinary-essay': '论述题(普通涉密人员)',
'management-essay': '论述题(保密管理人员)',
}
const ExamOnline: React.FC = () => {
const { examId } = useParams<{ examId: string }>()
const navigate = useNavigate()
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [examData, setExamData] = useState<GetExamResponse | null>(null)
const [groupedQuestions, setGroupedQuestions] = useState<
Record<string, Question[]>
>({})
const [essayChoice, setEssayChoice] = useState<'ordinary' | 'management' | null>(null)
// 加载考试详情
useEffect(() => {
if (!examId) {
message.error('考试ID不存在')
navigate('/exam/prepare')
return
}
const loadExam = async () => {
setLoading(true)
try {
const res = await examApi.getExam(Number(examId), false)
if (res.success && res.data) {
setExamData(res.data)
// 按题型分组
const grouped = groupQuestionsByType(res.data.questions)
setGroupedQuestions(grouped)
// 恢复答题进度
loadProgress(res.data.questions)
} else {
message.error('加载考试失败')
navigate('/exam/prepare')
}
} catch (error: any) {
message.error(error.response?.data?.message || '加载考试失败')
navigate('/exam/prepare')
} finally {
setLoading(false)
}
}
loadExam()
}, [examId, navigate])
// 按题型分组题目
const groupQuestionsByType = (questions: Question[]) => {
const grouped: Record<string, Question[]> = {}
questions.forEach((q) => {
if (!grouped[q.type]) {
grouped[q.type] = []
}
grouped[q.type].push(q)
})
return grouped
}
// 保存答题进度到 localStorage
const saveProgress = () => {
if (!examId) return
const values = form.getFieldsValue()
const progress = {
answers: values,
essayChoice,
timestamp: Date.now(),
}
localStorage.setItem(`exam_progress_${examId}`, JSON.stringify(progress))
}
// 从 localStorage 恢复答题进度
const loadProgress = (_questions: Question[]) => {
if (!examId) return
const saved = localStorage.getItem(`exam_progress_${examId}`)
if (saved) {
try {
const progress = JSON.parse(saved)
// 恢复表单值
if (progress.answers) {
form.setFieldsValue(progress.answers)
}
// 恢复论述题选择
if (progress.essayChoice) {
setEssayChoice(progress.essayChoice)
}
message.success('已恢复上次答题进度')
} catch (e) {
console.error('恢复进度失败', e)
}
}
}
// 监听表单变化,自动保存进度
useEffect(() => {
const timer = setInterval(() => {
saveProgress()
}, 5000) // 每5秒自动保存一次
return () => clearInterval(timer)
}, [examId, essayChoice])
// 提交考试
const handleSubmit = async () => {
// 验证论述题选择
if (!essayChoice) {
message.warning('请选择要作答的论述题')
return
}
// 验证表单
try {
await form.validateFields()
} catch (error) {
message.warning('请完成所有题目的作答')
return
}
Modal.confirm({
title: '确认提交',
icon: <ExclamationCircleOutlined />,
content: '提交后将无法修改答案,确定要提交吗?',
okText: '确定提交',
cancelText: '再检查一下',
onOk: async () => {
setSubmitting(true)
try {
const values = form.getFieldsValue()
const answers: Record<string, any> = {}
// 转换答案格式
Object.keys(values).forEach((key) => {
const questionId = key.replace('question_', '')
answers[questionId] = values[key]
})
const submitData: SubmitExamRequest = {
answers,
essay_choice: essayChoice!,
}
const res = await examApi.submitExam(Number(examId), submitData)
if (res.success) {
message.success('提交成功')
// 清除进度
localStorage.removeItem(`exam_progress_${examId}`)
// 跳转到成绩页,传递提交结果
navigate(`/exam/${examId}/result`, {
state: { submitResult: res.data }
})
} else {
message.error(res.message || '提交失败')
}
} catch (error: any) {
message.error(error.response?.data?.message || '提交失败,请稍后重试')
} finally {
setSubmitting(false)
}
},
})
}
// 渲染填空题
const renderFillInBlank = (question: Question, index: number) => {
// 获取答案数量(如果有 answer 字段)
const answerCount = question.answer
? Array.isArray(question.answer)
? question.answer.length
: 1
: 1
return (
<Card className={styles.questionCard} key={question.id}>
<div className={styles.questionHeader}>
<Text strong>
{index + 1}. {question.content}
</Text>
</div>
<Form.Item
name={`question_${question.id}`}
rules={[{ required: true, message: '请填写答案' }]}
initialValue={Array(answerCount).fill('')}
>
<Space direction="vertical" style={{ width: '100%' }}>
{Array.from({ length: answerCount }).map((_, i) => (
<Input
key={i}
placeholder={`${i + 1}`}
onChange={(e) => {
const value = form.getFieldValue(`question_${question.id}`) || []
value[i] = e.target.value
form.setFieldValue(`question_${question.id}`, value)
}}
/>
))}
</Space>
</Form.Item>
</Card>
)
}
// 渲染判断题
const renderTrueFalse = (question: Question, index: number) => {
return (
<Card className={styles.questionCard} key={question.id}>
<div className={styles.questionHeader}>
<Text strong>
{index + 1}. {question.content}
</Text>
</div>
<Form.Item
name={`question_${question.id}`}
rules={[{ required: true, message: '请选择答案' }]}
>
<Radio.Group>
<Space direction="vertical">
<Radio value="true"></Radio>
<Radio value="false"></Radio>
</Space>
</Radio.Group>
</Form.Item>
</Card>
)
}
// 渲染单选题
const renderMultipleChoice = (question: Question, index: number) => {
return (
<Card className={styles.questionCard} key={question.id}>
<div className={styles.questionHeader}>
<Text strong>
{index + 1}. {question.content}
</Text>
</div>
<Form.Item
name={`question_${question.id}`}
rules={[{ required: true, message: '请选择答案' }]}
>
<Radio.Group>
<Space direction="vertical">
{question.options.map((opt) => (
<Radio key={opt.key} value={opt.key}>
{opt.key}. {opt.value}
</Radio>
))}
</Space>
</Radio.Group>
</Form.Item>
</Card>
)
}
// 渲染多选题
const renderMultipleSelection = (question: Question, index: number) => {
return (
<Card className={styles.questionCard} key={question.id}>
<div className={styles.questionHeader}>
<Text strong>
{index + 1}. {question.content}
</Text>
</div>
<Form.Item
name={`question_${question.id}`}
rules={[{ required: true, message: '请选择答案' }]}
>
<Checkbox.Group>
<Space direction="vertical">
{question.options.map((opt) => (
<Checkbox key={opt.key} value={opt.key}>
{opt.key}. {opt.value}
</Checkbox>
))}
</Space>
</Checkbox.Group>
</Form.Item>
</Card>
)
}
// 渲染简答题
const renderShortAnswer = (question: Question, index: number) => {
return (
<Card className={styles.questionCard} key={question.id}>
<div className={styles.questionHeader}>
<Text strong>
{index + 1}. {question.content}
</Text>
<Text type="secondary" style={{ fontSize: '13px', marginLeft: '8px' }}>
</Text>
</div>
<Form.Item name={`question_${question.id}`}>
<TextArea rows={4} placeholder="请输入答案" />
</Form.Item>
</Card>
)
}
// 渲染论述题
const renderEssay = (questions: Question[]) => {
const ordinaryEssay = questions.find((q) => q.type === 'ordinary-essay')
const managementEssay = questions.find((q) => q.type === 'management-essay')
if (!ordinaryEssay || !managementEssay) return null
return (
<div className={styles.essaySection}>
<Card className={styles.essayChoiceCard}>
<Title level={4}></Title>
<Paragraph type="secondary">
</Paragraph>
<Radio.Group
value={essayChoice}
onChange={(e) => setEssayChoice(e.target.value)}
className={styles.essayRadioGroup}
>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Card
className={`${styles.essayOptionCard} ${
essayChoice === 'ordinary' ? styles.selected : ''
}`}
>
<Radio value="ordinary">
<Text strong></Text>
</Radio>
<div className={styles.essayContent}>
<Paragraph>{ordinaryEssay.content}</Paragraph>
</div>
</Card>
<Card
className={`${styles.essayOptionCard} ${
essayChoice === 'management' ? styles.selected : ''
}`}
>
<Radio value="management">
<Text strong></Text>
</Radio>
<div className={styles.essayContent}>
<Paragraph>{managementEssay.content}</Paragraph>
</div>
</Card>
</Space>
</Radio.Group>
</Card>
{essayChoice && (
<Card className={styles.questionCard}>
<div className={styles.questionHeader}>
<Text strong>
{essayChoice === 'ordinary'
? '普通涉密人员论述题'
: '保密管理人员论述题'}
</Text>
</div>
<Paragraph>{essayChoice === 'ordinary' ? ordinaryEssay.content : managementEssay.content}</Paragraph>
<Form.Item
name={`question_${essayChoice === 'ordinary' ? ordinaryEssay.id : managementEssay.id}`}
rules={[{ required: true, message: '请完成论述题作答' }]}
>
<TextArea rows={8} placeholder="请输入您的答案建议300字以上" showCount />
</Form.Item>
</Card>
)}
</div>
)
}
// 渲染题目组
const renderQuestionGroup = (type: string, questions: Question[]) => {
let startIndex = 0
// 计算该题型的起始序号
Object.keys(groupedQuestions)
.filter((t) => TYPE_ORDER[t] < TYPE_ORDER[type])
.forEach((t) => {
startIndex += groupedQuestions[t].length
})
return (
<div key={type} className={styles.questionGroup}>
<Divider orientation="left">
<Title level={3}>{TYPE_NAME[type]}</Title>
</Divider>
{questions.map((question, index) => {
switch (type) {
case 'fill-in-blank':
return renderFillInBlank(question, startIndex + index)
case 'true-false':
return renderTrueFalse(question, startIndex + index)
case 'multiple-choice':
return renderMultipleChoice(question, startIndex + index)
case 'multiple-selection':
return renderMultipleSelection(question, startIndex + index)
case 'short-answer':
return renderShortAnswer(question, startIndex + index)
default:
return null
}
})}
</div>
)
}
if (loading) {
return (
<div className={styles.loadingContainer}>
<Spin size="large" />
<Text style={{ marginTop: 16 }}>...</Text>
</div>
)
}
if (!examData) {
return null
}
// 获取论述题
const essayQuestions = [
...(groupedQuestions['ordinary-essay'] || []),
...(groupedQuestions['management-essay'] || []),
]
return (
<div className={styles.container}>
{/* 固定顶栏 */}
<div className={styles.fixedTopBar}>
<div className={styles.topBarContent}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/exam/prepare')}
className={styles.backButton}
type="text"
>
</Button>
<Title level={3} className={styles.title}>
线
</Title>
<div style={{ width: 80 }} />
</div>
</div>
{/* 主内容区 */}
<div className={styles.content}>
<Card className={styles.examInfoCard}>
<Title level={4}></Title>
<ul>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
</ul>
</Card>
<Form form={form} layout="vertical">
{/* 按题型顺序渲染题目 */}
{Object.keys(groupedQuestions)
.filter((type) => type !== 'ordinary-essay' && type !== 'management-essay')
.sort((a, b) => TYPE_ORDER[a] - TYPE_ORDER[b])
.map((type) => renderQuestionGroup(type, groupedQuestions[type]))}
{/* 渲染论述题(二选一) */}
{essayQuestions.length > 0 && (
<div className={styles.questionGroup}>
<Divider orientation="left">
<Title level={3}></Title>
</Divider>
{renderEssay(essayQuestions)}
</div>
)}
{/* 提交按钮 */}
<div className={styles.submitSection}>
<Button
type="primary"
size="large"
icon={<CheckCircleOutlined />}
onClick={handleSubmit}
loading={submitting}
className={styles.submitButton}
>
</Button>
</div>
</Form>
</div>
</div>
)
}
export default ExamOnline

View File

@ -0,0 +1,86 @@
.container {
min-height: calc(100vh - 64px);
padding: 16px;
background: #f0f2f5;
@media (min-width: 768px) {
padding: 24px;
}
}
.header {
margin-bottom: 16px;
padding: 12px 16px;
@media (min-width: 768px) {
margin-bottom: 24px;
padding: 16px 24px;
}
}
.backButton {
border: none;
padding: 4px 15px;
}
.content {
max-width: 900px;
margin: 0 auto;
}
.titleSection {
text-align: center;
margin-bottom: 32px;
.titleIcon {
font-size: 48px;
color: #fa8c16;
margin-bottom: 16px;
}
h2 {
margin-bottom: 8px;
}
}
.infoCard {
margin-bottom: 24px;
}
.tipCard {
margin-bottom: 32px;
ul {
margin: 0;
padding-left: 20px;
li {
margin-bottom: 8px;
color: #595959;
}
}
}
.actionButtons {
display: flex;
justify-content: center;
width: 100%;
@media (max-width: 576px) {
flex-direction: column;
button {
width: 100%;
}
}
}
.primaryButton {
background: #fa8c16;
border-color: #fa8c16;
&:hover {
background: #ff9c2e;
border-color: #ff9c2e;
}
}

View File

@ -0,0 +1,151 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, Button, Typography, Descriptions, Space, message } from 'antd'
import {
ArrowLeftOutlined,
FileTextOutlined,
PrinterOutlined,
EditOutlined,
} from '@ant-design/icons'
import * as examApi from '../api/exam'
import { DEFAULT_EXAM_CONFIG, DEFAULT_SCORE_CONFIG } from '../types/exam'
import styles from './ExamPrepare.module.less'
const { Title, Paragraph } = Typography
const ExamPrepare: React.FC = () => {
const navigate = useNavigate()
const [loading, setLoading] = useState(false)
// 生成在线考试
const handleStartOnlineExam = async () => {
setLoading(true)
try {
const res = await examApi.generateExam()
if (res.success && res.data) {
const examId = res.data.exam_id
message.success('考试已生成')
navigate(`/exam/${examId}/online`)
} else {
message.error(res.message || '生成考试失败')
}
} catch (error: any) {
if (error.response?.data?.message) {
message.error(error.response.data.message)
} else {
message.error('生成考试失败,请稍后重试')
}
} finally {
setLoading(false)
}
}
// 生成试卷打印
const handlePrintExam = async () => {
setLoading(true)
try {
const res = await examApi.generateExam()
if (res.success && res.data) {
const examId = res.data.exam_id
message.success('试卷已生成')
navigate(`/exam/${examId}/print`)
} else {
message.error(res.message || '生成试卷失败')
}
} catch (error: any) {
if (error.response?.data?.message) {
message.error(error.response.data.message)
} else {
message.error('生成试卷失败,请稍后重试')
}
} finally {
setLoading(false)
}
}
return (
<div className={styles.container}>
<Card className={styles.header}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/')}
className={styles.backButton}
>
</Button>
</Card>
<Card className={styles.content}>
<div className={styles.titleSection}>
<FileTextOutlined className={styles.titleIcon} />
<Title level={2}></Title>
<Paragraph type="secondary">
线
</Paragraph>
</div>
<Card title="考试说明" className={styles.infoCard}>
<Descriptions column={1} bordered>
<Descriptions.Item label="填空题">
{DEFAULT_EXAM_CONFIG.fill_in_blank} {DEFAULT_SCORE_CONFIG.fill_in_blank} {DEFAULT_EXAM_CONFIG.fill_in_blank * DEFAULT_SCORE_CONFIG.fill_in_blank}
</Descriptions.Item>
<Descriptions.Item label="判断题">
{DEFAULT_EXAM_CONFIG.true_false} {DEFAULT_SCORE_CONFIG.true_false} {DEFAULT_EXAM_CONFIG.true_false * DEFAULT_SCORE_CONFIG.true_false}
</Descriptions.Item>
<Descriptions.Item label="单选题">
{DEFAULT_EXAM_CONFIG.multiple_choice} {DEFAULT_SCORE_CONFIG.multiple_choice} {DEFAULT_EXAM_CONFIG.multiple_choice * DEFAULT_SCORE_CONFIG.multiple_choice}
</Descriptions.Item>
<Descriptions.Item label="多选题">
{DEFAULT_EXAM_CONFIG.multiple_selection} {DEFAULT_SCORE_CONFIG.multiple_selection} {DEFAULT_EXAM_CONFIG.multiple_selection * DEFAULT_SCORE_CONFIG.multiple_selection}
</Descriptions.Item>
<Descriptions.Item label="简答题">
{DEFAULT_EXAM_CONFIG.short_answer}
</Descriptions.Item>
<Descriptions.Item label="论述题">
2 1 {DEFAULT_SCORE_CONFIG.essay}
<br />
<span style={{ fontSize: '13px', color: '#8c8c8c' }}>
1 1
</span>
</Descriptions.Item>
<Descriptions.Item label="总分">100 </Descriptions.Item>
<Descriptions.Item label="建议时间">120 </Descriptions.Item>
</Descriptions>
</Card>
<Card title="温馨提示" className={styles.tipCard}>
<ul>
<li>线</li>
<li></li>
<li>使 AI </li>
<li></li>
<li></li>
</ul>
</Card>
<Space size="large" className={styles.actionButtons}>
<Button
type="primary"
size="large"
icon={<EditOutlined />}
onClick={handleStartOnlineExam}
loading={loading}
className={styles.primaryButton}
>
线
</Button>
<Button
size="large"
icon={<PrinterOutlined />}
onClick={handlePrintExam}
loading={loading}
>
</Button>
</Space>
</Card>
</div>
)
}
export default ExamPrepare

View File

@ -0,0 +1,514 @@
.container {
min-height: 100vh;
background: #fff;
padding: 20px;
}
// 操作按钮区(打印时隐藏)
.actionBar {
position: sticky;
top: 0;
z-index: 1000;
background: #fff;
padding: 16px 0;
margin-bottom: 24px;
border-bottom: 1px solid #e5e5ea;
display: flex;
justify-content: space-between;
align-items: center;
.backButton {
color: #007aff;
border-color: #007aff;
&:hover {
color: #0051d5;
border-color: #0051d5;
}
}
}
// 加载状态
.loadingContainer {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #fafafa;
}
// 打印内容区
.printContent {
max-width: 210mm; // A4纸宽度
margin: 0 auto;
background: #fff;
padding: 0;
}
// 试卷头部
.paperHeader {
text-align: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 2px solid #1d1d1f;
.paperTitle {
margin: 0 0 16px 0 !important;
color: #1d1d1f !important;
font-weight: 700 !important;
font-size: 28px !important;
}
.examInfo {
display: flex;
justify-content: space-around;
margin-top: 16px;
.infoItem {
font-size: 16px;
color: #1d1d1f;
font-weight: 500;
}
}
}
// 考试说明卡片
.instructionCard {
margin-bottom: 24px;
border-radius: 8px;
border: 1px solid #d1d1d6;
:global(.ant-card-body) {
padding: 20px;
}
h4 {
margin-bottom: 12px;
color: #1d1d1f;
font-weight: 600;
font-size: 16px;
}
ul {
margin: 0;
padding-left: 20px;
color: #1d1d1f;
line-height: 1.8;
li {
margin-bottom: 6px;
font-size: 14px;
}
}
}
// 题目组
.questionGroup {
margin-bottom: 32px;
page-break-inside: avoid;
.groupHeader {
display: flex;
align-items: baseline;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 1px solid #d1d1d6;
.groupTitle {
font-size: 18px;
color: #1d1d1f;
font-weight: 700;
}
.groupScore {
font-size: 14px;
margin-left: 8px;
color: #6e6e73;
}
}
.questionsList {
padding-left: 0;
}
}
// 题目项
.questionItem {
margin-bottom: 24px;
page-break-inside: avoid;
.questionContent {
margin-bottom: 12px;
line-height: 1.8;
span {
font-size: 15px;
color: #1d1d1f;
}
}
.optionsList {
margin: 12px 0;
padding-left: 20px;
.optionItem {
margin-bottom: 8px;
line-height: 1.6;
font-size: 14px;
color: #1d1d1f;
}
}
.answerArea {
margin-top: 12px;
padding: 8px 0;
span {
font-size: 14px;
color: #1d1d1f;
}
.blankLine {
margin-bottom: 8px;
line-height: 2;
font-size: 14px;
}
.answerLines {
margin-top: 8px;
.answerLine {
line-height: 2;
margin-bottom: 4px;
font-size: 14px;
color: #1d1d1f;
}
}
.essayAnswer {
:global(.ant-typography) {
margin-bottom: 8px;
line-height: 1.8;
font-size: 14px;
color: #1d1d1f;
}
}
}
}
// 打印样式
@media print {
// 隐藏不需要打印的元素
.noPrint,
:global(.noPrint) {
display: none !important;
}
// A4纸张设置
@page {
size: A4;
margin: 2cm;
}
.container {
background: #fff;
padding: 0;
}
.printContent {
max-width: 100%;
margin: 0;
padding: 0;
}
.paperHeader {
margin-bottom: 20px;
padding-bottom: 12px;
.paperTitle {
font-size: 24pt !important;
}
.examInfo {
.infoItem {
font-size: 12pt;
}
}
}
.instructionCard {
margin-bottom: 20px;
border: 1px solid #000;
:global(.ant-card-body) {
padding: 16px;
}
h4 {
font-size: 14pt;
}
ul {
li {
font-size: 11pt;
}
}
}
.questionGroup {
margin-bottom: 28px;
page-break-inside: avoid;
.groupHeader {
.groupTitle {
font-size: 16pt;
}
.groupScore {
font-size: 12pt;
}
}
}
.questionItem {
margin-bottom: 20px;
page-break-inside: avoid;
.questionContent {
span {
font-size: 12pt;
}
}
.optionsList {
.optionItem {
font-size: 11pt;
}
}
.answerArea {
span {
font-size: 11pt;
}
.blankLine {
font-size: 11pt;
}
.answerLines {
.answerLine {
font-size: 11pt;
}
}
.essayAnswer {
:global(.ant-typography) {
font-size: 11pt;
}
}
}
}
// 强制分页
.pageBreak {
page-break-after: always;
}
// 避免在题目中间分页
.questionItem {
page-break-inside: avoid;
}
// 黑白打印优化
* {
color: #000 !important;
background: #fff !important;
box-shadow: none !important;
text-shadow: none !important;
}
// 保留边框
.paperHeader,
.instructionCard,
.groupHeader {
border-color: #000 !important;
}
}
// 响应式设计 - 移动端
@media (max-width: 768px) {
.container {
padding: 12px;
}
.actionBar {
padding: 12px 0;
flex-direction: column;
gap: 12px;
:global(.ant-space) {
width: 100%;
button {
flex: 1;
}
}
}
.printContent {
max-width: 100%;
}
.paperHeader {
.paperTitle {
font-size: 22px !important;
}
.examInfo {
flex-direction: column;
gap: 8px;
.infoItem {
font-size: 14px;
}
}
}
.instructionCard {
:global(.ant-card-body) {
padding: 16px;
}
h4 {
font-size: 14px;
}
ul {
li {
font-size: 13px;
}
}
}
.questionGroup {
.groupHeader {
flex-direction: column;
align-items: flex-start;
gap: 4px;
.groupTitle {
font-size: 16px;
}
.groupScore {
font-size: 13px;
}
}
}
.questionItem {
.questionContent {
span {
font-size: 14px;
}
}
.optionsList {
padding-left: 16px;
.optionItem {
font-size: 13px;
}
}
.answerArea {
span {
font-size: 13px;
}
.blankLine {
font-size: 13px;
}
.answerLines {
.answerLine {
font-size: 13px;
}
}
.essayAnswer {
:global(.ant-typography) {
font-size: 13px;
}
}
}
}
}
// 响应式设计 - 平板
@media (min-width: 769px) and (max-width: 1024px) {
.container {
padding: 16px;
}
.printContent {
max-width: 190mm;
}
.paperHeader {
.paperTitle {
font-size: 26px !important;
}
.examInfo {
.infoItem {
font-size: 15px;
}
}
}
.questionGroup {
.groupHeader {
.groupTitle {
font-size: 17px;
}
}
}
.questionItem {
.questionContent {
span {
font-size: 14px;
}
}
}
}
// 响应式设计 - PC端
@media (min-width: 1025px) {
.container {
padding: 24px;
}
.printContent {
max-width: 210mm;
padding: 0 20px;
}
.actionBar {
padding: 20px 0;
}
.paperHeader {
.paperTitle {
font-size: 28px !important;
}
.examInfo {
.infoItem {
font-size: 16px;
}
}
}
}

475
web/src/pages/ExamPrint.tsx Normal file
View File

@ -0,0 +1,475 @@
import React, { useState, useEffect } from 'react'
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
import { Card, Button, Typography, message, Spin, Space } from 'antd'
import { ArrowLeftOutlined, PrinterOutlined, FileTextOutlined } from '@ant-design/icons'
import * as examApi from '../api/exam'
import type { Question } from '../types/question'
import type { GetExamResponse } from '../types/exam'
import styles from './ExamPrint.module.less'
const { Title, Paragraph, Text } = Typography
// 题型顺序映射
const TYPE_ORDER: Record<string, number> = {
'fill-in-blank': 1,
'true-false': 2,
'multiple-choice': 3,
'multiple-selection': 4,
'short-answer': 5,
'ordinary-essay': 6,
'management-essay': 6,
}
// 题型名称映射
const TYPE_NAME: Record<string, string> = {
'fill-in-blank': '一、填空题',
'true-false': '二、判断题',
'multiple-choice': '三、单选题',
'multiple-selection': '四、多选题',
'short-answer': '五、简答题',
'ordinary-essay': '六、论述题',
'management-essay': '六、论述题',
}
// 题型分值映射
const TYPE_SCORE: Record<string, number> = {
'fill-in-blank': 2.0,
'true-false': 2.0,
'multiple-choice': 1.0,
'multiple-selection': 2.5,
'short-answer': 0, // 不计分
'ordinary-essay': 25.0,
'management-essay': 25.0,
}
const ExamPrint: React.FC = () => {
const { examId } = useParams<{ examId: string }>()
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const showAnswer = searchParams.get('show_answer') === 'true'
const [loading, setLoading] = useState(false)
const [examData, setExamData] = useState<GetExamResponse | null>(null)
const [groupedQuestions, setGroupedQuestions] = useState<Record<string, Question[]>>({})
// 加载考试详情
useEffect(() => {
if (!examId) {
message.error('考试ID不存在')
navigate('/exam/prepare')
return
}
const loadExam = async () => {
setLoading(true)
try {
const res = await examApi.getExam(Number(examId), showAnswer)
if (res.success && res.data) {
setExamData(res.data)
// 按题型分组
const grouped = groupQuestionsByType(res.data.questions)
setGroupedQuestions(grouped)
} else {
message.error('加载考试失败')
navigate('/exam/prepare')
}
} catch (error: any) {
message.error(error.response?.data?.message || '加载考试失败')
navigate('/exam/prepare')
} finally {
setLoading(false)
}
}
loadExam()
}, [examId, showAnswer, navigate])
// 按题型分组题目
const groupQuestionsByType = (questions: Question[]) => {
const grouped: Record<string, Question[]> = {}
questions.forEach((q) => {
if (!grouped[q.type]) {
grouped[q.type] = []
}
grouped[q.type].push(q)
})
return grouped
}
// 打印试卷(不显示答案)
const handlePrintPaper = () => {
if (showAnswer) {
// 重新加载不显示答案的页面
window.location.href = `/exam/${examId}/print?show_answer=false`
} else {
window.print()
}
}
// 打印答案(显示答案)
const handlePrintAnswer = () => {
if (!showAnswer) {
// 重新加载显示答案的页面
window.location.href = `/exam/${examId}/print?show_answer=true`
} else {
window.print()
}
}
// 格式化答案显示
const formatAnswer = (question: Question): string => {
if (!question.answer) return ''
switch (question.type) {
case 'fill-in-blank':
if (Array.isArray(question.answer)) {
return question.answer.join('、')
}
return String(question.answer)
case 'true-false':
return question.answer === 'true' || question.answer === true ? '正确' : '错误'
case 'multiple-choice':
return String(question.answer)
case 'multiple-selection':
if (Array.isArray(question.answer)) {
return question.answer.sort().join('')
}
return String(question.answer)
case 'short-answer':
case 'ordinary-essay':
case 'management-essay':
return String(question.answer)
default:
return String(question.answer)
}
}
// 渲染填空题
const renderFillInBlank = (question: Question, index: number) => {
// 获取答案数量
const answerCount = question.answer && Array.isArray(question.answer)
? question.answer.length
: 1
return (
<div key={question.id} className={styles.questionItem}>
<div className={styles.questionContent}>
<Text strong>
{index + 1}. {question.content}
</Text>
</div>
<div className={styles.answerArea}>
{showAnswer ? (
<Text>{formatAnswer(question)}</Text>
) : (
<>
{Array.from({ length: answerCount }).map((_, i) => (
<div key={i} className={styles.blankLine}>
{i + 1} __________________________________________
</div>
))}
</>
)}
</div>
</div>
)
}
// 渲染判断题
const renderTrueFalse = (question: Question, index: number) => {
return (
<div key={question.id} className={styles.questionItem}>
<div className={styles.questionContent}>
<Text strong>
{index + 1}. {question.content}
</Text>
</div>
<div className={styles.answerArea}>
{showAnswer ? (
<Text>{formatAnswer(question)}</Text>
) : (
<Text>____</Text>
)}
</div>
</div>
)
}
// 渲染单选题
const renderMultipleChoice = (question: Question, index: number) => {
return (
<div key={question.id} className={styles.questionItem}>
<div className={styles.questionContent}>
<Text strong>
{index + 1}. {question.content}
</Text>
</div>
<div className={styles.optionsList}>
{question.options.map((opt) => (
<div key={opt.key} className={styles.optionItem}>
{opt.key}. {opt.value}
</div>
))}
</div>
<div className={styles.answerArea}>
{showAnswer ? (
<Text>{formatAnswer(question)}</Text>
) : (
<Text>____</Text>
)}
</div>
</div>
)
}
// 渲染多选题
const renderMultipleSelection = (question: Question, index: number) => {
return (
<div key={question.id} className={styles.questionItem}>
<div className={styles.questionContent}>
<Text strong>
{index + 1}. {question.content}
</Text>
</div>
<div className={styles.optionsList}>
{question.options.map((opt) => (
<div key={opt.key} className={styles.optionItem}>
{opt.key}. {opt.value}
</div>
))}
</div>
<div className={styles.answerArea}>
{showAnswer ? (
<Text>{formatAnswer(question)}</Text>
) : (
<Text>____</Text>
)}
</div>
</div>
)
}
// 渲染简答题
const renderShortAnswer = (question: Question, index: number) => {
return (
<div key={question.id} className={styles.questionItem}>
<div className={styles.questionContent}>
<Text strong>
{index + 1}. {question.content}
</Text>
{!showAnswer && (
<Text type="secondary" style={{ fontSize: '12px', marginLeft: '8px' }}>
</Text>
)}
</div>
<div className={styles.answerArea}>
{showAnswer ? (
<div className={styles.essayAnswer}>
<Text strong></Text>
<Paragraph>{formatAnswer(question)}</Paragraph>
</div>
) : (
<div className={styles.answerLines}>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className={styles.answerLine}>
_____________________________________________________________________________
</div>
))}
</div>
)}
</div>
</div>
)
}
// 渲染论述题
const renderEssay = (question: Question, index: number) => {
return (
<div key={question.id} className={styles.questionItem}>
<div className={styles.questionContent}>
<Text strong>
{index + 1}. {question.content}
</Text>
</div>
<div className={styles.answerArea}>
{showAnswer ? (
<div className={styles.essayAnswer}>
<Text strong></Text>
<Paragraph>{formatAnswer(question)}</Paragraph>
</div>
) : (
<div className={styles.answerLines}>
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} className={styles.answerLine}>
_____________________________________________________________________________
</div>
))}
</div>
)}
</div>
</div>
)
}
// 渲染题目组
const renderQuestionGroup = (type: string, questions: Question[]) => {
let startIndex = 0
// 计算该题型的起始序号
Object.keys(groupedQuestions)
.filter((t) => TYPE_ORDER[t] < TYPE_ORDER[type])
.forEach((t) => {
startIndex += groupedQuestions[t].length
})
// 计算该题型总分
const totalScore = questions.length * TYPE_SCORE[type]
return (
<div key={type} className={styles.questionGroup}>
<div className={styles.groupHeader}>
<Text strong className={styles.groupTitle}>
{TYPE_NAME[type]}
</Text>
{TYPE_SCORE[type] > 0 && (
<Text type="secondary" className={styles.groupScore}>
{questions.length}{TYPE_SCORE[type]}{totalScore}
</Text>
)}
</div>
<div className={styles.questionsList}>
{questions.map((question, index) => {
switch (type) {
case 'fill-in-blank':
return renderFillInBlank(question, startIndex + index)
case 'true-false':
return renderTrueFalse(question, startIndex + index)
case 'multiple-choice':
return renderMultipleChoice(question, startIndex + index)
case 'multiple-selection':
return renderMultipleSelection(question, startIndex + index)
case 'short-answer':
return renderShortAnswer(question, startIndex + index)
case 'ordinary-essay':
case 'management-essay':
return renderEssay(question, startIndex + index)
default:
return null
}
})}
</div>
</div>
)
}
if (loading) {
return (
<div className={styles.loadingContainer}>
<Spin size="large" />
<Text style={{ marginTop: 16 }}>...</Text>
</div>
)
}
if (!examData) {
return null
}
// 获取论述题(合并普通和管理两类)
const essayQuestions = [
...(groupedQuestions['ordinary-essay'] || []),
...(groupedQuestions['management-essay'] || []),
]
return (
<div className={styles.container}>
{/* 操作按钮区 - 打印时隐藏 */}
<div className={`${styles.actionBar} noPrint`}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/exam/new')}
className={styles.backButton}
>
</Button>
<Space>
<Button
type="default"
icon={<FileTextOutlined />}
onClick={handlePrintPaper}
>
</Button>
<Button
type="primary"
icon={<PrinterOutlined />}
onClick={handlePrintAnswer}
>
</Button>
</Space>
</div>
{/* 打印内容区 */}
<div className={styles.printContent}>
{/* 试卷头部 */}
<div className={styles.paperHeader}>
<Title level={2} className={styles.paperTitle}>
{showAnswer ? '(答案)' : ''}
</Title>
<div className={styles.examInfo}>
<div className={styles.infoItem}>
__________________
</div>
<div className={styles.infoItem}>
__________________
</div>
</div>
</div>
{/* 考试说明 */}
{!showAnswer && (
<Card className={styles.instructionCard}>
<Title level={4}></Title>
<ul>
<li>10090</li>
<li></li>
<li></li>
<li>21</li>
</ul>
</Card>
)}
{/* 按题型渲染题目 */}
{Object.keys(groupedQuestions)
.filter((type) => type !== 'ordinary-essay' && type !== 'management-essay')
.sort((a, b) => TYPE_ORDER[a] - TYPE_ORDER[b])
.map((type) => renderQuestionGroup(type, groupedQuestions[type]))}
{/* 论述题部分 */}
{essayQuestions.length > 0 && (
<div className={styles.questionGroup}>
<div className={styles.groupHeader}>
<Text strong className={styles.groupTitle}>
{TYPE_NAME['ordinary-essay']}
</Text>
<Text type="secondary" className={styles.groupScore}>
2125
</Text>
</div>
<div className={styles.questionsList}>
{essayQuestions.map((question, index) => renderEssay(question, index))}
</div>
</div>
)}
</div>
</div>
)
}
export default ExamPrint

View File

@ -0,0 +1,468 @@
.container {
min-height: 100vh;
padding: 24px 16px;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
@media (min-width: 768px) {
padding: 40px 24px;
}
}
// 加载状态
.loadingContainer {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
}
// 成绩大卡片
.scoreCard {
max-width: 800px;
margin: 0 auto 24px;
border-radius: 16px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
border: none;
overflow: hidden;
animation: slideDown 0.5s ease-out;
:global(.ant-card-body) {
padding: 40px 32px;
@media (max-width: 768px) {
padding: 24px 20px;
}
}
}
.scoreHeader {
text-align: center;
margin-bottom: 32px;
.trophyIcon {
font-size: 72px;
margin-bottom: 16px;
animation: bounce 1s ease-in-out infinite;
&.passed {
color: #52c41a;
}
&.failed {
color: #ff4d4f;
}
}
.scoreTitle {
margin: 0 !important;
font-weight: 700;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
}
.scoreContent {
text-align: center;
margin-bottom: 32px;
:global(.ant-statistic) {
display: inline-block;
}
:global(.ant-statistic-title) {
margin-bottom: 8px;
color: #595959;
}
}
.scoreBadge {
margin-top: 16px;
text-align: center;
}
.progressSection {
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid #f0f0f0;
}
// 信息卡片
.infoCard,
.detailCard,
.wrongQuestionsCard,
.actionCard {
max-width: 800px;
margin: 0 auto 24px;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
border: none;
:global(.ant-card-head) {
border-bottom: 1px solid #f0f0f0;
background: #fafafa;
border-radius: 12px 12px 0 0;
:global(.ant-card-head-title) {
font-weight: 600;
font-size: 16px;
}
}
:global(.ant-card-body) {
padding: 24px;
@media (max-width: 768px) {
padding: 16px;
}
}
}
// 题型得分卡片
.typeCard {
text-align: center;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
transform: translateY(-2px);
}
:global(.ant-card-body) {
padding: 20px 16px;
}
:global(.ant-statistic-title) {
font-size: 14px;
margin-bottom: 8px;
color: #595959;
}
}
.typeInfo {
margin-top: 8px;
font-size: 12px;
}
// 错题列表
.wrongQuestionsCollapse {
background: transparent;
border: none;
:global(.ant-collapse-item) {
margin-bottom: 16px;
border: 1px solid #f0f0f0;
border-radius: 8px;
overflow: hidden;
background: #ffffff;
&:last-child {
margin-bottom: 0;
}
}
:global(.ant-collapse-header) {
background: #fafafa;
padding: 16px 20px;
font-size: 15px;
border-radius: 8px !important;
&:hover {
background: #f5f5f5;
}
}
:global(.ant-collapse-content) {
border-top: 1px solid #f0f0f0;
}
:global(.ant-collapse-content-box) {
padding: 20px;
}
}
.wrongQuestionHeader {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
.wrongIcon {
color: #ff4d4f;
font-size: 18px;
}
@media (max-width: 576px) {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
}
.wrongQuestionContent {
:global(.ant-typography) {
margin-bottom: 16px;
}
.options {
margin: 16px 0;
padding: 16px;
background: #fafafa;
border-radius: 8px;
}
.option {
padding: 8px 0;
color: #595959;
line-height: 1.6;
&:not(:last-child) {
border-bottom: 1px solid #f0f0f0;
}
}
}
.answerBlock {
padding: 16px;
background: #fafafa;
border-radius: 8px;
.answerContent {
margin-top: 8px;
}
.wrongAnswer {
color: #ff4d4f;
font-weight: 600;
font-size: 15px;
}
.correctAnswer {
color: #52c41a;
font-weight: 600;
font-size: 15px;
}
}
// AI评分块
.aiGradingBlock {
margin-top: 24px;
padding: 20px;
background: linear-gradient(135deg, #fef3e7 0%, #fef9f3 100%);
border-radius: 8px;
border: 1px solid #ffd591;
:global(.ant-divider) {
margin: 16px 0;
border-color: #ffd591;
}
:global(.ant-divider-inner-text) {
font-weight: 600;
color: #fa8c16;
}
:global(.ant-typography) {
margin-bottom: 8px;
}
}
.messageBlock {
margin-top: 16px;
padding: 12px 16px;
background: #f0f0f0;
border-radius: 6px;
}
// 操作按钮区域
.actionCard {
:global(.ant-card-body) {
padding: 32px 24px;
text-align: center;
}
}
.actionButtons {
display: flex;
justify-content: center;
width: 100%;
@media (max-width: 768px) {
flex-direction: column;
align-items: stretch;
:global(.ant-btn) {
width: 100%;
}
}
:global(.ant-btn-lg) {
height: 48px;
font-size: 16px;
font-weight: 500;
border-radius: 24px;
min-width: 180px;
@media (max-width: 768px) {
min-width: auto;
}
}
:global(.ant-btn-primary) {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
&:hover {
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
box-shadow: 0 6px 16px rgba(102, 126, 234, 0.5);
transform: translateY(-2px);
}
}
:global(.ant-btn:not(.ant-btn-primary)) {
&:hover {
border-color: #667eea;
color: #667eea;
}
}
}
// 动画
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes bounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
// 响应式优化
@media (max-width: 768px) {
.container {
padding: 16px 12px;
}
.scoreCard {
border-radius: 12px;
:global(.ant-card-body) {
padding: 24px 16px;
}
.scoreHeader {
margin-bottom: 24px;
.trophyIcon {
font-size: 56px;
}
.scoreTitle {
font-size: 24px !important;
}
}
.scoreContent {
:global(.ant-statistic-content-value) {
font-size: 48px !important;
}
}
.progressSection {
margin-top: 24px;
padding-top: 20px;
}
}
.infoCard,
.detailCard,
.wrongQuestionsCard,
.actionCard {
border-radius: 8px;
}
.wrongQuestionsCollapse {
:global(.ant-collapse-header) {
padding: 12px 16px;
font-size: 14px;
}
:global(.ant-collapse-content-box) {
padding: 16px;
}
}
.answerBlock {
padding: 12px;
font-size: 14px;
}
.aiGradingBlock {
padding: 16px;
font-size: 14px;
}
}
@media (min-width: 769px) and (max-width: 1024px) {
.container {
padding: 32px 24px;
}
.scoreCard,
.infoCard,
.detailCard,
.wrongQuestionsCard,
.actionCard {
max-width: 900px;
}
}
@media (min-width: 1025px) {
.container {
padding: 48px 32px;
}
.scoreCard,
.infoCard,
.detailCard,
.wrongQuestionsCard,
.actionCard {
max-width: 1000px;
}
.scoreCard {
:global(.ant-card-body) {
padding: 48px 40px;
}
}
.scoreHeader {
.trophyIcon {
font-size: 80px;
}
}
.actionCard {
:global(.ant-card-body) {
padding: 40px 32px;
}
}
}

View File

@ -0,0 +1,590 @@
import React, { useState, useEffect } from 'react'
import { useParams, useNavigate, useLocation } from 'react-router-dom'
import {
Card,
Button,
Typography,
Descriptions,
Statistic,
Progress,
Space,
Collapse,
Tag,
Spin,
message,
Row,
Col,
Divider,
} from 'antd'
import {
TrophyOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
FileTextOutlined,
RedoOutlined,
HomeOutlined,
WarningOutlined,
} from '@ant-design/icons'
import * as examApi from '../api/exam'
import type { GetExamResponse, SubmitExamResponse } from '../types/exam'
import type { Question, QuestionType } from '../types/question'
import styles from './ExamResult.module.less'
const { Title, Paragraph, Text } = Typography
const { Panel } = Collapse
// 题型名称映射
const TYPE_NAME: Record<QuestionType, string> = {
'fill-in-blank': '填空题',
'true-false': '判断题',
'multiple-choice': '单选题',
'multiple-selection': '多选题',
'short-answer': '简答题',
'ordinary-essay': '论述题(普通)',
'management-essay': '论述题(管理)',
}
// 题型顺序映射
const TYPE_ORDER: Record<string, number> = {
'fill-in-blank': 1,
'true-false': 2,
'multiple-choice': 3,
'multiple-selection': 4,
'short-answer': 5,
'ordinary-essay': 6,
'management-essay': 6,
}
interface DetailedResult {
correct: boolean
score: number
message?: string
ai_grading?: {
score: number
feedback: string
suggestion: string
}
}
const ExamResult: React.FC = () => {
const { examId } = useParams<{ examId: string }>()
const navigate = useNavigate()
const location = useLocation()
const [loading, setLoading] = useState(false)
const [examData, setExamData] = useState<GetExamResponse | null>(null)
const [submitResult, setSubmitResult] = useState<SubmitExamResponse | null>(null)
// 从导航 state 获取提交结果,或从 API 加载
useEffect(() => {
if (!examId) {
message.error('考试ID不存在')
navigate('/exam/new')
return
}
// 如果从提交页面跳转过来,会带有 submitResult
const stateResult = location.state?.submitResult as SubmitExamResponse | undefined
if (stateResult) {
setSubmitResult(stateResult)
}
// 加载考试详情(包含题目信息)
const loadExamData = async () => {
setLoading(true)
try {
// showAnswer=true 可以获取正确答案
const res = await examApi.getExam(Number(examId), true)
if (res.success && res.data) {
setExamData(res.data)
// 如果没有从 state 获取到结果,提示用户
if (!stateResult) {
// 检查考试是否已提交
if (res.data.exam.status !== 'submitted') {
message.warning('考试尚未提交,请先完成考试')
navigate(`/exam/${examId}/online`)
return
}
// 由于详细结果仅在提交时返回直接访问URL无法获取详细评分
// 我们只显示总分,建议用户查看试卷答案
message.info('详细评分仅在提交考试时显示,您可以查看试卷答案')
}
} else {
message.error('加载考试详情失败')
navigate('/exam/new')
}
} catch (error: any) {
message.error(error.response?.data?.message || '加载失败')
navigate('/exam/new')
} finally {
setLoading(false)
}
}
loadExamData()
}, [examId, navigate, location.state])
// 格式化时间
const formatTime = (time?: string) => {
if (!time) return '-'
return new Date(time).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
// 格式化答案显示
const formatAnswer = (answer: any): string => {
if (answer === null || answer === undefined) return '-'
if (Array.isArray(answer)) {
return answer.join(', ')
}
if (typeof answer === 'boolean') {
return answer ? '正确' : '错误'
}
return String(answer)
}
// 计算统计数据
const calculateStats = () => {
if (!submitResult || !examData) {
return {
totalQuestions: 0,
correctCount: 0,
wrongCount: 0,
accuracy: 0,
typeScores: {},
wrongQuestions: [],
}
}
const results = submitResult.detailed_results
let correctCount = 0
let wrongCount = 0
const typeScores: Record<string, { correct: number; total: number; score: number }> = {}
const wrongQuestions: Array<{ question: Question; result: DetailedResult }> = []
examData.questions.forEach((question) => {
const result = results[String(question.id)]
if (!result) return
const typeName = TYPE_NAME[question.type]
if (!typeScores[typeName]) {
typeScores[typeName] = { correct: 0, total: 0, score: 0 }
}
typeScores[typeName].total += 1
typeScores[typeName].score += result.score
if (result.correct) {
correctCount += 1
typeScores[typeName].correct += 1
} else {
wrongCount += 1
wrongQuestions.push({ question, result })
}
})
const accuracy = examData.questions.length > 0
? (correctCount / examData.questions.length) * 100
: 0
return {
totalQuestions: examData.questions.length,
correctCount,
wrongCount,
accuracy,
typeScores,
wrongQuestions,
}
}
// 渲染错题详情
const renderWrongQuestion = (question: Question, result: DetailedResult, index: number) => {
// 解析用户答案和正确答案
let userAnswer = '-'
let correctAnswer = '-'
try {
const answers = JSON.parse(examData!.exam.answers)
const userAnswerData = answers.answers?.[String(question.id)]
userAnswer = formatAnswer(userAnswerData)
// 从 question.answer 获取正确答案
correctAnswer = formatAnswer(question.answer)
} catch (e) {
console.error('解析答案失败', e)
}
return (
<Panel
header={
<div className={styles.wrongQuestionHeader}>
<Space>
<CloseCircleOutlined className={styles.wrongIcon} />
<Text strong>{index + 1}. {TYPE_NAME[question.type]}</Text>
</Space>
<Tag color="red">-{result.score} </Tag>
</div>
}
key={question.id}
>
<div className={styles.wrongQuestionContent}>
<Paragraph strong>{question.content}</Paragraph>
{/* 选项(如果有) */}
{question.options && question.options.length > 0 && (
<div className={styles.options}>
{question.options.map((opt) => (
<div key={opt.key} className={styles.option}>
{opt.key}. {opt.value}
</div>
))}
</div>
)}
<Divider />
<Row gutter={[16, 16]}>
<Col xs={24} md={12}>
<div className={styles.answerBlock}>
<Text type="secondary"></Text>
<div className={styles.answerContent}>
<Text className={styles.wrongAnswer}>{userAnswer}</Text>
</div>
</div>
</Col>
<Col xs={24} md={12}>
<div className={styles.answerBlock}>
<Text type="secondary"></Text>
<div className={styles.answerContent}>
<Text className={styles.correctAnswer}>{correctAnswer}</Text>
</div>
</div>
</Col>
</Row>
{/* AI评分反馈 */}
{result.ai_grading && (
<div className={styles.aiGradingBlock}>
<Divider>AI </Divider>
<Space direction="vertical" style={{ width: '100%' }}>
<div>
<Text strong></Text>
<Text type="warning" style={{ fontSize: '16px' }}>
{result.ai_grading.score}
</Text>
</div>
{result.ai_grading.feedback && (
<div>
<Text strong></Text>
<Paragraph>{result.ai_grading.feedback}</Paragraph>
</div>
)}
{result.ai_grading.suggestion && (
<div>
<Text strong></Text>
<Paragraph type="secondary">{result.ai_grading.suggestion}</Paragraph>
</div>
)}
</Space>
</div>
)}
{/* 其他提示信息 */}
{result.message && !result.ai_grading && (
<div className={styles.messageBlock}>
<Text type="secondary">{result.message}</Text>
</div>
)}
</div>
</Panel>
)
}
if (loading) {
return (
<div className={styles.loadingContainer}>
<Spin size="large" />
<Text style={{ marginTop: 16 }}>...</Text>
</div>
)
}
if (!examData) {
return (
<div className={styles.loadingContainer}>
<WarningOutlined style={{ fontSize: 48, color: '#faad14', marginBottom: 16 }} />
<Text></Text>
<Button type="primary" onClick={() => navigate('/exam/new')} style={{ marginTop: 16 }}>
</Button>
</div>
)
}
// 如果没有详细结果,只显示总分
if (!submitResult) {
const score = examData.exam.score
const isPassed = score >= 60
return (
<div className={styles.container}>
{/* 简化的成绩卡片 */}
<Card className={styles.scoreCard}>
<div className={styles.scoreHeader}>
<TrophyOutlined className={`${styles.trophyIcon} ${isPassed ? styles.passed : styles.failed}`} />
<Title level={2} className={styles.scoreTitle}>
{isPassed ? '恭喜通过!' : '继续加油!'}
</Title>
</div>
<div className={styles.scoreContent}>
<Statistic
title={<span style={{ fontSize: '18px' }}></span>}
value={score}
suffix="/ 100"
valueStyle={{
color: isPassed ? '#52c41a' : '#ff4d4f',
fontSize: '64px',
fontWeight: 700,
}}
/>
<div className={styles.scoreBadge}>
{isPassed ? (
<Tag color="success" style={{ fontSize: '16px', padding: '8px 16px' }}>
<CheckCircleOutlined />
</Tag>
) : (
<Tag color="error" style={{ fontSize: '16px', padding: '8px 16px' }}>
<CloseCircleOutlined />
</Tag>
)}
</div>
</div>
</Card>
{/* 考试信息 */}
<Card title="考试信息" className={styles.infoCard}>
<Descriptions column={{ xs: 1, sm: 2 }} bordered>
<Descriptions.Item label="创建时间">
{formatTime(examData.exam.created_at)}
</Descriptions.Item>
<Descriptions.Item label="提交时间">
{formatTime(examData.exam.submitted_at)}
</Descriptions.Item>
<Descriptions.Item label="题目总数">
{examData.questions.length}
</Descriptions.Item>
<Descriptions.Item label="总分">
<Text style={{ fontWeight: 600, color: isPassed ? '#52c41a' : '#ff4d4f' }}>
{score}
</Text>
</Descriptions.Item>
</Descriptions>
<Paragraph type="secondary" style={{ marginTop: 16, marginBottom: 0 }}>
</Paragraph>
</Card>
{/* 操作按钮 */}
<Card className={styles.actionCard}>
<Space size="large" wrap className={styles.actionButtons}>
<Button
type="primary"
size="large"
icon={<FileTextOutlined />}
onClick={() => navigate(`/exam/${examId}/print?show_answer=true`)}
>
</Button>
<Button
size="large"
icon={<RedoOutlined />}
onClick={() => navigate('/exam/new')}
>
</Button>
<Button
size="large"
icon={<HomeOutlined />}
onClick={() => navigate('/')}
>
</Button>
</Space>
</Card>
</div>
)
}
const stats = calculateStats()
const isPassed = submitResult.score >= 60
return (
<div className={styles.container}>
{/* 成绩大卡片 */}
<Card className={styles.scoreCard}>
<div className={styles.scoreHeader}>
<TrophyOutlined className={`${styles.trophyIcon} ${isPassed ? styles.passed : styles.failed}`} />
<Title level={2} className={styles.scoreTitle}>
{isPassed ? '恭喜通过!' : '继续加油!'}
</Title>
</div>
<div className={styles.scoreContent}>
<Statistic
title={<span style={{ fontSize: '18px' }}></span>}
value={submitResult.score}
suffix="/ 100"
valueStyle={{
color: isPassed ? '#52c41a' : '#ff4d4f',
fontSize: '64px',
fontWeight: 700,
}}
/>
<div className={styles.scoreBadge}>
{isPassed ? (
<Tag color="success" style={{ fontSize: '16px', padding: '8px 16px' }}>
<CheckCircleOutlined />
</Tag>
) : (
<Tag color="error" style={{ fontSize: '16px', padding: '8px 16px' }}>
<CloseCircleOutlined />
</Tag>
)}
</div>
</div>
{/* 正确率进度条 */}
<div className={styles.progressSection}>
<Text strong style={{ fontSize: '16px', marginBottom: 8, display: 'block' }}>
</Text>
<Progress
percent={Math.round(stats.accuracy)}
strokeColor={{
'0%': isPassed ? '#87d068' : '#ff7875',
'100%': isPassed ? '#52c41a' : '#ff4d4f',
}}
strokeWidth={12}
format={(percent) => (
<span style={{ fontSize: '16px', fontWeight: 600 }}>
{percent}%
</span>
)}
/>
</div>
</Card>
{/* 考试信息 */}
<Card title="考试信息" className={styles.infoCard}>
<Descriptions column={{ xs: 1, sm: 2 }} bordered>
<Descriptions.Item label="创建时间">
{formatTime(examData.exam.created_at)}
</Descriptions.Item>
<Descriptions.Item label="提交时间">
{formatTime(examData.exam.submitted_at)}
</Descriptions.Item>
<Descriptions.Item label="题目总数">
{stats.totalQuestions}
</Descriptions.Item>
<Descriptions.Item label="答对题数">
<Text style={{ color: '#52c41a', fontWeight: 600 }}>
{stats.correctCount}
</Text>
</Descriptions.Item>
<Descriptions.Item label="答错题数">
<Text style={{ color: '#ff4d4f', fontWeight: 600 }}>
{stats.wrongCount}
</Text>
</Descriptions.Item>
<Descriptions.Item label="正确率">
<Text style={{ fontWeight: 600 }}>
{stats.accuracy.toFixed(1)}%
</Text>
</Descriptions.Item>
</Descriptions>
</Card>
{/* 各题型得分明细 */}
<Card title="各题型得分明细" className={styles.detailCard}>
<Row gutter={[16, 16]}>
{Object.entries(stats.typeScores).map(([typeName, data]) => (
<Col xs={12} sm={8} md={6} key={typeName}>
<Card className={styles.typeCard}>
<Statistic
title={typeName}
value={data.score}
suffix="分"
valueStyle={{ fontSize: '24px', fontWeight: 600 }}
/>
<div className={styles.typeInfo}>
<Text type="secondary">
{data.correct}/{data.total}
</Text>
</div>
</Card>
</Col>
))}
</Row>
</Card>
{/* 错题列表 */}
{stats.wrongQuestions.length > 0 && (
<Card
title={
<Space>
<CloseCircleOutlined style={{ color: '#ff4d4f' }} />
<span>{stats.wrongQuestions.length} </span>
</Space>
}
className={styles.wrongQuestionsCard}
>
<Collapse accordion className={styles.wrongQuestionsCollapse}>
{stats.wrongQuestions
.sort((a, b) => TYPE_ORDER[a.question.type] - TYPE_ORDER[b.question.type])
.map(({ question, result }, index) =>
renderWrongQuestion(question, result, index)
)}
</Collapse>
</Card>
)}
{/* 操作按钮 */}
<Card className={styles.actionCard}>
<Space size="large" wrap className={styles.actionButtons}>
<Button
type="primary"
size="large"
icon={<FileTextOutlined />}
onClick={() => navigate(`/exam/${examId}/print?show_answer=true`)}
>
</Button>
<Button
size="large"
icon={<RedoOutlined />}
onClick={() => navigate('/exam/new')}
>
</Button>
<Button
size="large"
icon={<HomeOutlined />}
onClick={() => navigate('/')}
>
</Button>
</Space>
</Card>
</div>
)
}
export default ExamResult

View File

@ -547,6 +547,30 @@ const Home: React.FC = () => {
</Card>
</Col>
<Col xs={24} sm={24} md={12} lg={8}>
<Card
hoverable
className={styles.quickCard}
onClick={() => navigate('/exam/new')}
>
<Space align="center" size="middle" style={{ width: '100%' }}>
<div
className={styles.quickIconWrapper}
style={{
background: 'linear-gradient(135deg, #fff7e6 0%, #ffe7ba 100%)',
borderColor: '#ffd591'
}}
>
<FileTextOutlined className={styles.quickIcon} style={{ color: '#fa8c16' }} />
</div>
<div style={{ flex: 1 }}>
<Title level={5} style={{ margin: 0 }}></Title>
<Paragraph type="secondary" style={{ margin: 0, fontSize: '13px' }}>线</Paragraph>
</div>
</Space>
</Card>
</Col>
{/* 仅 yanlongqi 用户显示题库管理 */}
{userInfo?.username === 'yanlongqi' && (
<>

87
web/src/types/exam.ts Normal file
View File

@ -0,0 +1,87 @@
import { Question } from './question'
// 考试记录
export interface Exam {
id: number
user_id: number
question_ids: string
answers: string
score: number
status: 'draft' | 'submitted'
submitted_at?: string
created_at: string
updated_at: string
}
// 考试题目配置
export interface ExamQuestionConfig {
fill_in_blank: number // 填空题数量
true_false: number // 判断题数量
multiple_choice: number // 单选题数量
multiple_selection: number // 多选题数量
short_answer: number // 简答题数量
ordinary_essay: number // 普通涉密人员论述题数量
management_essay: number // 保密管理人员论述题数量
}
// 考试分值配置
export interface ExamScoreConfig {
fill_in_blank: number // 填空题分值
true_false: number // 判断题分值
multiple_choice: number // 单选题分值
multiple_selection: number // 多选题分值
essay: number // 论述题分值
}
// 生成考试响应
export interface GenerateExamResponse {
exam_id: number
question_ids: number[]
created_at: string
}
// 获取考试响应
export interface GetExamResponse {
exam: Exam
questions: Question[]
}
// 提交考试请求
export interface SubmitExamRequest {
answers: Record<string, any> // question_id -> answer
essay_choice: 'ordinary' | 'management' // 论述题选择
}
// 提交考试响应
export interface SubmitExamResponse {
score: number
detailed_results: Record<string, {
correct: boolean
score: number
message?: string
ai_grading?: {
score: number
feedback: string
suggestion: string
}
}>
}
// 默认配置
export const DEFAULT_EXAM_CONFIG: ExamQuestionConfig = {
fill_in_blank: 10,
true_false: 10,
multiple_choice: 10,
multiple_selection: 10,
short_answer: 2,
ordinary_essay: 1,
management_essay: 1,
}
export const DEFAULT_SCORE_CONFIG: ExamScoreConfig = {
fill_in_blank: 2.0,
true_false: 2.0,
multiple_choice: 1.0,
multiple_selection: 2.5,
essay: 25.0,
}