1939 lines
56 KiB
Vue
1939 lines
56 KiB
Vue
<template>
|
||
<div class="practice-container">
|
||
<!-- 加载状态 -->
|
||
<div v-if="isGenerating" class="loading-container">
|
||
<el-icon class="is-loading" :size="48"><Loading /></el-icon>
|
||
<h3>试题动态生成中...</h3>
|
||
<p>正在根据课程知识点智能生成考试试题,请稍候</p>
|
||
<p class="time-estimate">(大约需要 1~3 分钟)</p>
|
||
</div>
|
||
|
||
<!-- 考试界面 -->
|
||
<div v-else-if="currentQuestion" class="practice-content">
|
||
<div class="practice-header">
|
||
<div class="header-info">
|
||
<h2>动态考试</h2>
|
||
<div class="progress-info">
|
||
<span>第{{ currentRound }}轮 - {{ currentRound === 1 ? '正式考试' : '错题重考' }}</span>
|
||
<span class="separator">|</span>
|
||
<span>第 {{ currentQuestionIndex + 1 }} / {{ totalQuestions }} 题</span>
|
||
<span class="separator">|</span>
|
||
<span>正确:{{ correctCount }}</span>
|
||
<span class="separator">|</span>
|
||
<span>错误:{{ wrongCount }}</span>
|
||
</div>
|
||
</div>
|
||
<el-button type="danger" size="small" @click="exitPractice">
|
||
退出考试
|
||
</el-button>
|
||
</div>
|
||
|
||
<div class="practice-main">
|
||
<!-- 题目内容 -->
|
||
<div class="question-section card">
|
||
<div class="question-type">
|
||
<el-tag :type="getQuestionTypeTag(currentQuestion.type)">
|
||
{{ getQuestionTypeText(currentQuestion.type) }}
|
||
</el-tag>
|
||
<span class="question-score">{{ currentQuestion.score }} 分</span>
|
||
</div>
|
||
|
||
<div class="question-content">
|
||
<h3>{{ currentQuestionIndex + 1 }}. {{ currentQuestion.title }}</h3>
|
||
|
||
<!-- 单选题/多选题 -->
|
||
<div v-if="['single', 'multiple'].includes(currentQuestion.type)" class="options-list">
|
||
<div
|
||
v-for="(option, index) in currentQuestion.options"
|
||
:key="index"
|
||
class="option-item"
|
||
:class="{
|
||
selected: isOptionSelected(index),
|
||
correct: showAnswer && option.isCorrect,
|
||
wrong: showAnswer && isOptionSelected(index) && !option.isCorrect
|
||
}"
|
||
@click="selectOption(index)"
|
||
>
|
||
<span class="option-label">{{ String.fromCharCode(65 + index) }}</span>
|
||
<span class="option-content">{{ option.content }}</span>
|
||
<el-icon v-if="showAnswer && option.isCorrect" class="result-icon correct">
|
||
<CircleCheck />
|
||
</el-icon>
|
||
<el-icon v-else-if="showAnswer && isOptionSelected(index) && !option.isCorrect" class="result-icon wrong">
|
||
<CircleClose />
|
||
</el-icon>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 判断题 -->
|
||
<div v-else-if="currentQuestion.type === 'judge'" class="judge-options">
|
||
<div class="judge-cards">
|
||
<div
|
||
class="judge-card"
|
||
:class="{
|
||
active: userAnswer.judge === true,
|
||
disabled: showAnswer,
|
||
correct: showAnswer && currentQuestion.correctAnswer === true,
|
||
wrong: showAnswer && userAnswer.judge === true && currentQuestion.correctAnswer !== true
|
||
}"
|
||
@click="!showAnswer && selectJudge(true)"
|
||
>
|
||
<div class="judge-icon correct-icon">
|
||
<el-icon :size="32"><CircleCheck /></el-icon>
|
||
</div>
|
||
<span class="judge-text">正确</span>
|
||
</div>
|
||
<div
|
||
class="judge-card"
|
||
:class="{
|
||
active: userAnswer.judge === false,
|
||
disabled: showAnswer,
|
||
correct: showAnswer && currentQuestion.correctAnswer === false,
|
||
wrong: showAnswer && userAnswer.judge === false && currentQuestion.correctAnswer !== false
|
||
}"
|
||
@click="!showAnswer && selectJudge(false)"
|
||
>
|
||
<div class="judge-icon wrong-icon">
|
||
<el-icon :size="32"><CircleClose /></el-icon>
|
||
</div>
|
||
<span class="judge-text">错误</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 填空题 -->
|
||
<div v-else-if="currentQuestion.type === 'blank'" class="blank-answer">
|
||
<div class="blank-input-wrapper">
|
||
<el-input
|
||
v-model="userAnswer.blank"
|
||
placeholder="请在此输入你的答案..."
|
||
:disabled="showAnswer"
|
||
@keyup.enter="handleBlankSubmit"
|
||
size="large"
|
||
clearable
|
||
>
|
||
<template #prefix>
|
||
<el-icon><Edit /></el-icon>
|
||
</template>
|
||
</el-input>
|
||
<p class="blank-hint">按 Enter 键提交答案</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 问答题 -->
|
||
<div v-else-if="currentQuestion.type === 'essay'" class="essay-answer">
|
||
<el-input
|
||
v-model="userAnswer.essay"
|
||
type="textarea"
|
||
:rows="6"
|
||
placeholder="请输入答案"
|
||
:disabled="showAnswer"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 答案解析 -->
|
||
<div v-if="showAnswer" class="answer-section">
|
||
<el-divider />
|
||
<!-- 正确答案的提示 -->
|
||
<div v-if="isAnswerCorrect" class="correct-feedback">
|
||
<div class="feedback-icon">
|
||
<el-icon :size="32" color="#67c23a"><CircleCheck /></el-icon>
|
||
</div>
|
||
<div class="feedback-text">
|
||
<h3>回答正确!</h3>
|
||
<p>正在进入下一题...</p>
|
||
</div>
|
||
</div>
|
||
<!-- 错误答案的解析 -->
|
||
<div v-else>
|
||
<div class="answer-title">答案解析</div>
|
||
<div class="correct-answer markdown-content">
|
||
<strong>正确答案:</strong>
|
||
<div v-html="renderMarkdown(formatCorrectAnswer(currentQuestion))"></div>
|
||
</div>
|
||
<div class="answer-explanation">
|
||
<strong>解析:</strong>
|
||
<div class="markdown-content" v-html="renderMarkdown(currentQuestion.explanation)"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 多选题、判断题、填空题和问答题的提交按钮 -->
|
||
<div v-if="(['multiple', 'judge', 'blank', 'essay'].includes(currentQuestion.type)) && !showAnswer" class="submit-section">
|
||
<el-button
|
||
type="primary"
|
||
@click="submitAnswer"
|
||
:disabled="!hasAnswer"
|
||
size="large"
|
||
>
|
||
{{ currentQuestion.type === 'multiple' ? '确认选择' : '提交答案' }}
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 无题目状态 -->
|
||
<div v-else class="no-questions">
|
||
<el-empty description="暂无题目" />
|
||
</div>
|
||
|
||
<!-- 答案解析弹窗 -->
|
||
<el-dialog
|
||
v-model="answerDialogVisible"
|
||
title="答案解析"
|
||
width="600px"
|
||
:close-on-click-modal="false"
|
||
:close-on-press-escape="false"
|
||
:show-close="false"
|
||
class="answer-dialog"
|
||
>
|
||
<div v-if="dialogQuestion" class="answer-dialog-content">
|
||
<div class="result-section">
|
||
<div class="result-icon-large wrong">
|
||
<el-icon :size="40"><CircleClose /></el-icon>
|
||
</div>
|
||
<h3 class="result-text">回答错误</h3>
|
||
</div>
|
||
|
||
<div class="dialog-scroll-area">
|
||
<div class="question-review">
|
||
<h4>题目:</h4>
|
||
<p>{{ dialogQuestion.title }}</p>
|
||
</div>
|
||
|
||
<div class="answer-review">
|
||
<h4>正确答案:</h4>
|
||
<div class="correct-answer-text markdown-content" v-html="renderMarkdown(formatCorrectAnswer(dialogQuestion))"></div>
|
||
</div>
|
||
|
||
<div class="explanation-review">
|
||
<h4>解析:</h4>
|
||
<div class="explanation-text markdown-content" v-html="renderMarkdown(dialogQuestion.explanation)"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<template #footer>
|
||
<div class="dialog-footer">
|
||
<el-button type="primary" @click="handleRemembered" size="large">
|
||
记住了
|
||
</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { ElMessage, ElMessageBox, ElDialog, ElLoading } from 'element-plus'
|
||
import { Loading, CircleCheck, CircleClose, Edit } from '@element-plus/icons-vue'
|
||
import { courseApi } from '@/api/course'
|
||
import { generateExam, judgeAnswer, recordMistake, getMistakes, updateRoundScore, type MistakeRecordItem } from '@/api/exam'
|
||
import { marked } from 'marked'
|
||
import DOMPurify from 'dompurify'
|
||
|
||
// 路由相关
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
|
||
// 课程和考试ID
|
||
const courseId = ref<number>(0)
|
||
const currentExamId = ref<number>(0) // 当前考试ID(三轮考试共用一个exam记录)
|
||
|
||
// 考试设置
|
||
const examSettings = ref({
|
||
single_choice_count: 4,
|
||
multiple_choice_count: 2,
|
||
true_false_count: 1,
|
||
fill_blank_count: 2,
|
||
essay_count: 1,
|
||
difficulty_level: 3,
|
||
duration_minutes: 10,
|
||
passing_score: 60
|
||
})
|
||
|
||
// 考试状态
|
||
const currentIndex = ref(0)
|
||
const showAnswer = ref(false)
|
||
const correctCount = ref(0)
|
||
const wrongCount = ref(0)
|
||
// const isLoading = ref(false) // Unused
|
||
const isGenerating = ref(true) // 是否正在生成试题
|
||
|
||
// 多轮考试相关
|
||
const currentRound = ref(1) // 当前轮次
|
||
const maxRounds = ref(3) // 最大轮次(可配置)
|
||
const roundScores = ref<number[]>([]) // 每轮得分
|
||
const wrongQuestions = ref<any[]>([]) // 错题集合
|
||
const currentWrongIndex = ref(0) // 当前错题索引
|
||
const isWrongQuestionMode = ref(false) // 是否为错题模式
|
||
|
||
// 答题进度保存相关
|
||
const PRACTICE_STORAGE_KEY = 'exam_practice_progress'
|
||
const userAnswers = ref<any[]>([]) // 用户答题记录
|
||
|
||
// 弹窗状态
|
||
const answerDialogVisible = ref(false)
|
||
const isAnswerCorrect = ref(false)
|
||
const dialogQuestion = ref<any>(null) // 弹窗要显示的题目(保存快照,避免currentQuestion变化)
|
||
|
||
// 用户答案
|
||
const userAnswer = reactive({
|
||
selected: [] as number[],
|
||
judge: null as boolean | null,
|
||
blank: '',
|
||
essay: '' // 新增问答题答案
|
||
})
|
||
|
||
// 试题数据
|
||
const questions = ref<any[]>([])
|
||
|
||
// 当前题目
|
||
const currentQuestion = computed(() => {
|
||
if (!questions.value || questions.value.length === 0) {
|
||
return null
|
||
}
|
||
return questions.value[currentIndex.value]
|
||
})
|
||
|
||
const totalQuestions = computed(() => {
|
||
return questions.value?.length || 0
|
||
})
|
||
|
||
const currentQuestionIndex = computed(() => {
|
||
return currentIndex.value
|
||
})
|
||
|
||
// 是否已答题
|
||
const hasAnswer = computed(() => {
|
||
const q = currentQuestion.value
|
||
if (!q) return false
|
||
|
||
if (q.type === 'single' || q.type === 'multiple') {
|
||
return userAnswer.selected.length > 0
|
||
} else if (q.type === 'judge') {
|
||
return userAnswer.judge !== null
|
||
} else if (q.type === 'blank') {
|
||
return userAnswer.blank.trim() !== ''
|
||
} else if (q.type === 'essay') {
|
||
return userAnswer.essay.trim() !== ''
|
||
}
|
||
return false
|
||
})
|
||
|
||
/**
|
||
* 退出考试
|
||
*/
|
||
const exitPractice = async () => {
|
||
try {
|
||
await ElMessageBox.confirm('确定要退出考试吗?', '提示', {
|
||
confirmButtonText: '确定',
|
||
cancelButtonText: '取消',
|
||
type: 'warning'
|
||
})
|
||
// 可以跳转到其他页面或返回上一页
|
||
ElMessage.success('已退出考试')
|
||
} catch {}
|
||
}
|
||
|
||
/**
|
||
* 重置考试
|
||
*/
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||
/**
|
||
* 清空答案
|
||
*/
|
||
const clearAnswer = () => {
|
||
userAnswer.selected = []
|
||
userAnswer.judge = null
|
||
userAnswer.blank = ''
|
||
userAnswer.essay = ''
|
||
}
|
||
|
||
/**
|
||
* 数据格式转换:Dify格式转前端格式
|
||
*/
|
||
const transformDifyQuestions = (difyQuestions: any[]): any[] => {
|
||
return difyQuestions.map((q, index) => {
|
||
const typeMap: Record<string, string> = {
|
||
'single_choice': 'single',
|
||
'multiple_choice': 'multiple',
|
||
'true_false': 'judge',
|
||
'fill_blank': 'blank',
|
||
'essay': 'essay'
|
||
}
|
||
|
||
const transformed: any = {
|
||
id: index + 1,
|
||
type: typeMap[q.type] || q.type,
|
||
title: q.topic?.title || q.topic || '',
|
||
score: 10 / difyQuestions.length, // 平均分配分值,总分10分
|
||
explanation: q.analysis || '',
|
||
knowledge_point_id: q.knowledge_point_id ? parseInt(q.knowledge_point_id) : null
|
||
}
|
||
|
||
// 处理选择题选项
|
||
if (q.type === 'single_choice' || q.type === 'multiple_choice') {
|
||
const options = q.topic?.options || {}
|
||
// 解析正确答案:支持 "A"、"A,B"、"A、B"、"A:xxx" 等多种格式
|
||
const correctAnswerStr = String(q.correct || '').trim()
|
||
|
||
console.log(`📝 开始解析题目[${index + 1}]`)
|
||
console.log(` 原始correct: "${correctAnswerStr}"`)
|
||
console.log(` 原始options:`, options)
|
||
|
||
// 更精确地提取正确答案字母(避免误提取选项内容中的字母)
|
||
let correctLetters: string[] = []
|
||
|
||
// 情况1:纯选项字母格式(如 "A", "B", "A,B", "A、B", "A,B,C,D,E")
|
||
const pureLetterMatch = correctAnswerStr.match(/^[A-Za-z]([,、\s]+[A-Za-z])*$/)
|
||
if (pureLetterMatch) {
|
||
correctLetters = correctAnswerStr.match(/[A-Za-z]/g)?.map(l => l.toUpperCase()) || []
|
||
console.log(` ✓ 匹配情况1(纯字母): correctLetters=[${correctLetters.join(',')}]`)
|
||
} else {
|
||
// 情况2:多选项带内容格式(如 "A:分组列,B:聚合函数,C:常量")
|
||
// 匹配所有 "X:" 或 "X:" 格式,提取字母
|
||
const multiOptionMatch = correctAnswerStr.match(/[A-Za-z](?=[::])/g)
|
||
if (multiOptionMatch && multiOptionMatch.length > 0) {
|
||
correctLetters = multiOptionMatch.map(l => l.toUpperCase())
|
||
console.log(` ✓ 匹配情况2(多选项带内容): correctLetters=[${correctLetters.join(',')}]`)
|
||
} else {
|
||
// 情况3:只有开头字母(如 "A" 后面直接跟非字母内容)
|
||
const firstLetterMatch = correctAnswerStr.match(/^([A-Za-z])(?![A-Za-z])/)
|
||
if (firstLetterMatch) {
|
||
correctLetters = [firstLetterMatch[1].toUpperCase()]
|
||
console.log(` ✓ 匹配情况3(开头字母): correctLetters=[${correctLetters.join(',')}]`)
|
||
} else {
|
||
// 情况4:无法解析,尝试从选项中查找匹配的答案
|
||
console.log(` ⚠️ 无法从correct字符串解析字母,尝试内容匹配`)
|
||
// 尝试在选项中查找完全匹配的内容
|
||
const optionValues = Object.values(options)
|
||
optionValues.forEach((optVal: any, idx: number) => {
|
||
const optContent = String(optVal || '')
|
||
if (optContent === correctAnswerStr || optContent.includes(correctAnswerStr)) {
|
||
const letter = String.fromCharCode(65 + idx)
|
||
correctLetters.push(letter)
|
||
console.log(` → 内容匹配: 选项${letter}="${optContent.substring(0, 30)}..."`)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
// 确保至少有一个正确答案
|
||
if (correctLetters.length === 0) {
|
||
console.warn(` ❌ 警告:未能解析出正确答案字母,默认使用第一个选项`)
|
||
correctLetters = ['A']
|
||
}
|
||
|
||
console.log(`🔍 解析题目[${index + 1}] - correct: "${q.correct}", correctLetters: [${correctLetters.join(', ')}]`)
|
||
|
||
// 获取所有选项的键并排序,确保顺序一致
|
||
const optionKeys = Object.keys(options).sort((a, b) => {
|
||
// 如果是数字键,按数字排序
|
||
const numA = parseInt(a)
|
||
const numB = parseInt(b)
|
||
if (!isNaN(numA) && !isNaN(numB)) return numA - numB
|
||
// 如果是字母键,按字母排序
|
||
return a.localeCompare(b)
|
||
})
|
||
|
||
console.log(` 选项键排序后: [${optionKeys.join(', ')}]`)
|
||
|
||
transformed.options = optionKeys.map((key, idx) => {
|
||
const opt = options[key]
|
||
const optStr = String(opt || '')
|
||
|
||
// 提取选项内容(去掉 "A:" 或 "A:" 前缀)
|
||
let content = optStr
|
||
// 匹配 "A:xxx" 或 "A:xxx" 格式(支持中英文冒号,支持A-Z所有字母)
|
||
const contentPrefixMatch = optStr.match(/^[A-Za-z]\s*[::]\s*(.*)$/)
|
||
if (contentPrefixMatch) {
|
||
content = contentPrefixMatch[1].trim()
|
||
}
|
||
|
||
// 提取当前选项的字母
|
||
// 优先级:1. 选项内容前缀 > 2. key字母 > 3. 索引推断
|
||
let optionLetter = ''
|
||
let letterSource = ''
|
||
|
||
// 1. 尝试从选项内容前缀提取(如 "A:xxx" 或 "A:xxx")
|
||
const prefixMatch = optStr.match(/^([A-Za-z])\s*[::]/)
|
||
if (prefixMatch) {
|
||
optionLetter = prefixMatch[1].toUpperCase()
|
||
letterSource = '内容前缀'
|
||
}
|
||
// 2. 尝试从 key 提取(如果 key 是单个字母 A-Z)
|
||
else if (key.match(/^[A-Za-z]$/)) {
|
||
optionLetter = key.toUpperCase()
|
||
letterSource = 'key字母'
|
||
}
|
||
// 3. 根据索引推断选项字母(0->A, 1->B, 2->C, 3->D)
|
||
else {
|
||
optionLetter = String.fromCharCode(65 + idx) // 65 = 'A'
|
||
letterSource = '索引推断'
|
||
}
|
||
|
||
// 判断当前选项是否正确:检查选项字母是否在正确答案列表中
|
||
const isCorrect = correctLetters.includes(optionLetter)
|
||
|
||
console.log(` 选项[${key}/${idx}]: letter=${optionLetter}(${letterSource}), isCorrect=${isCorrect}, correctLetters=[${correctLetters.join(',')}], content="${content.substring(0, 25)}..."`)
|
||
|
||
return { content, isCorrect, optionLetter }
|
||
})
|
||
// 同时保存原始的correct字段用于显示
|
||
transformed.correctAnswer = q.correct
|
||
}
|
||
|
||
// 处理判断题
|
||
if (q.type === 'true_false') {
|
||
// 保存原始的correct字符串用于显示
|
||
transformed.correctAnswerText = q.correct // 用于显示:"正确"或"错误"
|
||
// 保存boolean值用于判断
|
||
transformed.correctAnswer = q.correct === '正确' || q.correct === 'true' || q.correct === true
|
||
}
|
||
|
||
// 处理填空题和问答题
|
||
if (q.type === 'fill_blank' || q.type === 'essay') {
|
||
transformed.correctAnswer = q.correct
|
||
}
|
||
|
||
// 确保explanation字段存在
|
||
if (!transformed.explanation && q.analysis) {
|
||
transformed.explanation = q.analysis
|
||
}
|
||
|
||
return transformed
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 初始化考试
|
||
*/
|
||
const initExam = async () => {
|
||
try {
|
||
// 获取courseId
|
||
courseId.value = Number(route.query.courseId)
|
||
if (!courseId.value) {
|
||
ElMessage.error('缺少课程ID参数')
|
||
router.back()
|
||
return
|
||
}
|
||
|
||
// 获取考试设置
|
||
const settingsRes = await courseApi.getExamSettings(courseId.value)
|
||
console.log('📊 获取考试设置响应:', settingsRes)
|
||
if (settingsRes.code === 200 && settingsRes.data) {
|
||
const settings = settingsRes.data
|
||
examSettings.value = {
|
||
single_choice_count: settings.single_choice_count || 4,
|
||
multiple_choice_count: settings.multiple_choice_count || 2,
|
||
true_false_count: settings.true_false_count || 1,
|
||
fill_blank_count: settings.fill_blank_count || 2,
|
||
essay_count: settings.essay_count || 1,
|
||
difficulty_level: settings.difficulty_level || 3,
|
||
duration_minutes: settings.duration_minutes || 10,
|
||
passing_score: settings.passing_score || 60
|
||
}
|
||
console.log('📊 应用的考试设置:', examSettings.value)
|
||
} else {
|
||
console.warn('⚠️ 未获取到考试设置,使用默认值:', examSettings.value)
|
||
}
|
||
|
||
// 生成第一轮考试试题
|
||
await loadQuestions()
|
||
} catch (error: any) {
|
||
console.error('初始化考试失败:', error)
|
||
ElMessage.error(error.message || '初始化考试失败')
|
||
isGenerating.value = false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 加载试题
|
||
*/
|
||
const loadQuestions = async () => {
|
||
try {
|
||
isGenerating.value = true
|
||
|
||
const requestData: any = {
|
||
course_id: courseId.value,
|
||
current_round: currentRound.value, // 新增:当前轮次
|
||
exam_id: currentRound.value === 1 ? undefined : currentExamId.value, // 第2、3轮传入exam_id
|
||
single_choice_count: examSettings.value.single_choice_count,
|
||
multiple_choice_count: examSettings.value.multiple_choice_count,
|
||
true_false_count: examSettings.value.true_false_count,
|
||
fill_blank_count: examSettings.value.fill_blank_count,
|
||
essay_count: examSettings.value.essay_count,
|
||
difficulty_level: examSettings.value.difficulty_level
|
||
}
|
||
|
||
// 第一轮考试不传mistake_records参数
|
||
// 第二、三轮考试传入上一轮的错题记录
|
||
if (currentRound.value > 1) {
|
||
// 用同一个exam_id获取上一轮的错题(传入round参数只获取上一轮的错题)
|
||
const previousRound = currentRound.value - 1
|
||
console.log(`📋 获取第${previousRound}轮错题记录 - exam_id: ${currentExamId.value}, round: ${previousRound}`)
|
||
|
||
const mistakesRes: any = await getMistakes(currentExamId.value, previousRound)
|
||
|
||
console.log('getMistakes原始响应:', mistakesRes)
|
||
console.log('getMistakes响应结构:', {
|
||
'mistakesRes.code': mistakesRes.code,
|
||
'mistakesRes.data': mistakesRes.data,
|
||
'mistakesRes.data.mistakes': mistakesRes.data?.mistakes,
|
||
'mistakes数量': mistakesRes.data?.mistakes?.length
|
||
})
|
||
|
||
// 响应格式:{code: 200, data: {mistakes: [...]}}
|
||
const mistakesData = mistakesRes.data?.mistakes || []
|
||
|
||
if (mistakesRes.code === 200 && mistakesData.length > 0) {
|
||
// 将错题记录转换为JSON字符串
|
||
const mistakeRecords = mistakesData.map((m: MistakeRecordItem) => ({
|
||
question_id: m.question_id || null,
|
||
knowledge_point_id: m.knowledge_point_id || null,
|
||
question_content: m.question_content,
|
||
correct_answer: m.correct_answer,
|
||
user_answer: m.user_answer
|
||
}))
|
||
requestData.mistake_records = JSON.stringify(mistakeRecords)
|
||
console.log(`✅ 错题记录已准备 - 数量: ${mistakeRecords.length}`)
|
||
} else {
|
||
// 没有错题,结束考试
|
||
console.log('⚠️ 没有错题记录,结束考试')
|
||
ElMessage.success('恭喜!没有错题,考试完成!')
|
||
finishAllRounds()
|
||
return
|
||
}
|
||
}
|
||
|
||
// 调用Dify生成试题
|
||
const response: any = await generateExam(requestData)
|
||
|
||
// 调试日志:输出完整响应
|
||
console.log('=== Dify响应调试 ===')
|
||
console.log('response.code:', response.code)
|
||
console.log('response.message:', response.message)
|
||
console.log('response.data:', response.data)
|
||
console.log('response.data.result类型:', typeof response.data?.result)
|
||
console.log('response.data.result长度:', response.data?.result?.length)
|
||
console.log('response.data.result前100字符:', response.data?.result?.substring(0, 100))
|
||
console.log('==================')
|
||
|
||
if (response.code === 200 && response.data?.result) {
|
||
// 解析result字段中的JSON字符串
|
||
const difyQuestions = JSON.parse(response.data.result)
|
||
|
||
// 调试:查看Dify返回的原始题目数据
|
||
console.log('🔍 Dify返回的原始题目(前2题):', difyQuestions.slice(0, 2))
|
||
console.log('🔍 第一题的correct字段:', difyQuestions[0]?.correct)
|
||
console.log('🔍 第一题的analysis字段:', difyQuestions[0]?.analysis)
|
||
|
||
// 转换为前端需要的格式
|
||
questions.value = transformDifyQuestions(difyQuestions)
|
||
|
||
// 调试:查看转换后的题目数据
|
||
console.log('✅ 转换后的题目(前2题):', questions.value.slice(0, 2))
|
||
console.log('✅ 第一题的correctAnswer:', questions.value[0]?.correctAnswer)
|
||
console.log('✅ 第一题的explanation:', questions.value[0]?.explanation)
|
||
|
||
isGenerating.value = false
|
||
|
||
// 第一轮:保存exam_id;第二三轮:复用相同的exam_id
|
||
if (currentRound.value === 1) {
|
||
currentExamId.value = response.data.exam_id
|
||
console.log(`✅ 第1轮:考试记录创建成功 - exam_id: ${currentExamId.value}`)
|
||
} else {
|
||
console.log(`✅ 第${currentRound.value}轮:复用考试记录 - exam_id: ${currentExamId.value}`)
|
||
}
|
||
|
||
ElMessage.success(`第${currentRound.value}轮考试试题生成成功,共${questions.value.length}题`)
|
||
} else {
|
||
console.error('❌ 试题生成失败 - response.code:', response.code, 'result为空:', !response.data?.result)
|
||
throw new Error(response.message || '试题生成失败')
|
||
}
|
||
} catch (error: any) {
|
||
console.error('加载试题失败:', error)
|
||
ElMessage.error(error.message || '加载试题失败,请重试')
|
||
isGenerating.value = false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 选择选项
|
||
*/
|
||
const selectOption = (index: number) => {
|
||
if (showAnswer.value) return
|
||
|
||
const q = currentQuestion.value
|
||
if (q.type === 'single') {
|
||
userAnswer.selected = [index]
|
||
// 单选题立即判断答案
|
||
checkAndHandleAnswer()
|
||
} else if (q.type === 'multiple') {
|
||
const idx = userAnswer.selected.indexOf(index)
|
||
if (idx > -1) {
|
||
userAnswer.selected.splice(idx, 1)
|
||
} else {
|
||
userAnswer.selected.push(index)
|
||
}
|
||
// 保存答题进度
|
||
saveProgress()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 判断选项是否被选中
|
||
*/
|
||
const isOptionSelected = (index: number) => {
|
||
return userAnswer.selected.includes(index)
|
||
}
|
||
|
||
/**
|
||
* 检查并处理答案
|
||
*/
|
||
const checkAndHandleAnswer = async () => {
|
||
const q = currentQuestion.value
|
||
let isCorrect = false
|
||
|
||
console.log(`🔍 开始检查答案 - 题型: ${q.type}, 题目: ${q.title.substring(0, 30)}...`)
|
||
|
||
// 立即保存题目快照(防止异步判断期间currentQuestion变化)
|
||
const questionSnapshot = { ...q }
|
||
|
||
// 主观题(填空题、问答题)需要调用AI判断
|
||
if (q.type === 'blank' || q.type === 'essay') {
|
||
isCorrect = await checkSubjectiveAnswer(questionSnapshot) // ← 传递快照
|
||
console.log(`📝 主观题AI判断结果: ${isCorrect}`)
|
||
} else {
|
||
// 客观题(单选、多选、判断)直接判断
|
||
isCorrect = checkObjectiveAnswer()
|
||
console.log(`📋 客观题本地判断结果: ${isCorrect}`)
|
||
}
|
||
|
||
isAnswerCorrect.value = isCorrect
|
||
console.log(`✅ 最终判断: ${isCorrect ? '正确✓' : '错误✗'}`)
|
||
|
||
if (isCorrect) {
|
||
correctCount.value++
|
||
// 显示正确提示
|
||
showAnswer.value = true
|
||
ElMessage.success('回答正确!')
|
||
// 延迟进入下一题
|
||
setTimeout(() => {
|
||
showAnswer.value = false
|
||
clearAnswer()
|
||
goToNextQuestion()
|
||
}, 1500)
|
||
} else {
|
||
wrongCount.value++
|
||
console.log(`❌ 答错了,准备记录错题`)
|
||
console.log('题目快照数据:', {
|
||
'type': questionSnapshot.type,
|
||
'title': questionSnapshot.title.substring(0, 30),
|
||
'correctAnswer': questionSnapshot.correctAnswer,
|
||
'explanation': questionSnapshot.explanation,
|
||
'explanation长度': questionSnapshot.explanation?.length
|
||
})
|
||
|
||
// 使用题目快照(而不是q,因为q可能已经变化)
|
||
dialogQuestion.value = questionSnapshot
|
||
console.log('保存到dialogQuestion:', {
|
||
'correctAnswer': dialogQuestion.value.correctAnswer,
|
||
'explanation': dialogQuestion.value.explanation,
|
||
'explanation前50字符': dialogQuestion.value.explanation?.substring(0, 50)
|
||
})
|
||
|
||
// 记录错题到数据库
|
||
await recordWrongAnswer()
|
||
// 答错显示解析弹窗
|
||
showAnswer.value = true
|
||
answerDialogVisible.value = true
|
||
console.log(`📢 显示答案解析弹窗 - answerDialogVisible: ${answerDialogVisible.value}`)
|
||
console.log('formatCorrectAnswer结果:', formatCorrectAnswer(dialogQuestion.value))
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 提交答案(用于多选题、填空题、问答题)
|
||
*/
|
||
const submitAnswer = () => {
|
||
checkAndHandleAnswer()
|
||
}
|
||
|
||
/**
|
||
* 检查客观题答案
|
||
*/
|
||
const checkObjectiveAnswer = (): boolean => {
|
||
const q = currentQuestion.value
|
||
|
||
if (q.type === 'single' || q.type === 'multiple') {
|
||
const correctIndexes = q.options
|
||
.map((opt: any, idx: number) => opt.isCorrect ? idx : -1)
|
||
.filter((idx: number) => idx !== -1)
|
||
|
||
// 调试日志:显示正确答案索引和用户选择
|
||
console.log(`🔍 客观题判断 - 题目: "${q.title.substring(0, 30)}..."`)
|
||
console.log(` 选项详情:`, q.options.map((opt: any, idx: number) => ({
|
||
index: idx,
|
||
letter: String.fromCharCode(65 + idx),
|
||
isCorrect: opt.isCorrect,
|
||
content: opt.content?.substring(0, 20) + '...'
|
||
})))
|
||
console.log(` 正确答案索引: [${correctIndexes.join(', ')}]`)
|
||
console.log(` 用户选择索引: [${userAnswer.selected.join(', ')}]`)
|
||
|
||
const isCorrect = JSON.stringify(userAnswer.selected.sort()) === JSON.stringify(correctIndexes.sort())
|
||
console.log(` 判断结果: ${isCorrect ? '✅正确' : '❌错误'}`)
|
||
|
||
return isCorrect
|
||
} else if (q.type === 'judge') {
|
||
console.log(`🔍 判断题判断 - 正确答案: ${q.correctAnswer}, 用户答案: ${userAnswer.judge}`)
|
||
return userAnswer.judge === q.correctAnswer
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
/**
|
||
* 检查主观题答案(调用AI判断)
|
||
*/
|
||
const checkSubjectiveAnswer = async (question: any): Promise<boolean> => {
|
||
const q = question // 使用传入的题目快照,而不是currentQuestion.value
|
||
const answer = q.type === 'blank' ? userAnswer.blank : userAnswer.essay
|
||
|
||
console.log('🔍 主观题检查 - 使用题目快照:', {
|
||
'type': q.type,
|
||
'title': q.title?.substring(0, 30),
|
||
'correctAnswer': q.correctAnswer,
|
||
'explanation': q.explanation?.substring(0, 50)
|
||
})
|
||
|
||
if (!answer || !answer.trim()) {
|
||
ElMessage.warning('请输入答案')
|
||
return false
|
||
}
|
||
|
||
// 显示AI分析提示
|
||
const loadingInstance = ElLoading.service({
|
||
lock: true,
|
||
text: 'AI 正在分析您的回答...',
|
||
background: 'rgba(0, 0, 0, 0.7)',
|
||
})
|
||
|
||
try {
|
||
const response = await judgeAnswer({
|
||
question: q.title,
|
||
correct_answer: q.correctAnswer || '',
|
||
user_answer: answer.trim(),
|
||
analysis: q.explanation || '' // Dify答案判断工作流需要analysis参数
|
||
})
|
||
|
||
const resp: any = response
|
||
console.log('🔍 AI答案判断结果:', {
|
||
'response.code': resp.code,
|
||
'response.data': resp.data,
|
||
'is_correct': resp.data?.is_correct
|
||
})
|
||
|
||
if (resp.code === 200) {
|
||
const isCorrect = resp.data?.is_correct || false
|
||
console.log(`✅ AI最终判断: ${isCorrect ? '正确' : '错误'}`)
|
||
loadingInstance.close()
|
||
return isCorrect
|
||
} else {
|
||
loadingInstance.close()
|
||
ElMessage.error('答案判断失败: ' + resp.message)
|
||
return false
|
||
}
|
||
} catch (error: any) {
|
||
loadingInstance.close()
|
||
console.error('答案判断失败:', error)
|
||
ElMessage.error('答案判断失败,请重试')
|
||
return false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 记录错题到数据库
|
||
*/
|
||
const recordWrongAnswer = async () => {
|
||
const q = currentQuestion.value
|
||
|
||
// 获取用户答案
|
||
let userAnswerText = ''
|
||
if (q.type === 'single' || q.type === 'multiple') {
|
||
userAnswerText = userAnswer.selected.map(idx => String.fromCharCode(65 + idx)).join(',')
|
||
} else if (q.type === 'judge') {
|
||
userAnswerText = userAnswer.judge ? '正确' : '错误'
|
||
} else if (q.type === 'blank') {
|
||
userAnswerText = userAnswer.blank
|
||
} else if (q.type === 'essay') {
|
||
userAnswerText = userAnswer.essay
|
||
}
|
||
|
||
try {
|
||
await recordMistake({
|
||
exam_id: currentExamId.value,
|
||
round: currentRound.value, // 记录当前轮次
|
||
question_id: null, // AI生成的题目没有question_id
|
||
knowledge_point_id: q.knowledge_point_id || null,
|
||
question_content: q.title,
|
||
correct_answer: formatCorrectAnswer(q),
|
||
user_answer: userAnswerText,
|
||
question_type: q.type // 新增:记录题型
|
||
})
|
||
} catch (error: any) {
|
||
console.error('记录错题失败:', error)
|
||
// 记录错题失败不影响答题流程,只打印日志
|
||
}
|
||
}
|
||
|
||
// 已被其他函数替代,保留以备后用
|
||
// const previousQuestion = () => {
|
||
// if (currentIndex.value > 0) {
|
||
// currentIndex.value--
|
||
// showAnswer.value = false
|
||
// clearAnswer()
|
||
// }
|
||
// }
|
||
|
||
/**
|
||
* 进入下一题
|
||
*/
|
||
const goToNextQuestion = () => {
|
||
if (currentIndex.value < questions.value.length - 1) {
|
||
currentIndex.value++
|
||
// 只有在没有被外部处理时才清空答案和隐藏答案
|
||
if (showAnswer.value) {
|
||
showAnswer.value = false
|
||
clearAnswer()
|
||
}
|
||
} else {
|
||
// 当前轮次考试结束
|
||
finishRound()
|
||
}
|
||
}
|
||
|
||
// 已被goToNextQuestion替代
|
||
// const nextQuestion = () => {
|
||
// goToNextQuestion()
|
||
// }
|
||
|
||
/**
|
||
* 记住了按钮点击处理
|
||
*/
|
||
const handleRemembered = () => {
|
||
answerDialogVisible.value = false
|
||
showAnswer.value = false
|
||
clearAnswer()
|
||
goToNextQuestion()
|
||
}
|
||
|
||
/**
|
||
* 判断题选择处理
|
||
*/
|
||
const handleJudgeChange = () => {
|
||
// 判断题选择后立即判断
|
||
checkAndHandleAnswer()
|
||
}
|
||
|
||
/**
|
||
* 判断题卡片点击
|
||
*/
|
||
const selectJudge = (value: boolean) => {
|
||
userAnswer.judge = value
|
||
checkAndHandleAnswer()
|
||
}
|
||
|
||
/**
|
||
* 填空题回车提交处理
|
||
*/
|
||
const handleBlankSubmit = () => {
|
||
if (userAnswer.blank.trim()) {
|
||
checkAndHandleAnswer()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 开始错题重考
|
||
*/
|
||
const startWrongQuestionRound = async () => {
|
||
// 计算当前轮次得分
|
||
const score = Math.round((correctCount.value / questions.value.length) * 100)
|
||
// 注意:roundScores 已在 finishRound 中更新,这里不再 push
|
||
|
||
// 调用API更新当前轮次的得分
|
||
try {
|
||
await updateRoundScore({
|
||
exam_id: currentExamId.value,
|
||
round: currentRound.value,
|
||
score: score
|
||
})
|
||
console.log(`✅ 更新第${currentRound.value}轮得分成功: ${score}`)
|
||
} catch (error: any) {
|
||
console.error('更新轮次得分失败:', error)
|
||
ElMessage.error('保存本轮成绩失败,请检查网络')
|
||
}
|
||
|
||
if (currentRound.value >= maxRounds.value) {
|
||
// 达到最大轮次,结束考试
|
||
finishAllRounds()
|
||
return
|
||
}
|
||
|
||
// 检查是否有错题
|
||
if (wrongCount.value === 0) {
|
||
ElMessage.success('恭喜!本轮考试全部正确!')
|
||
finishAllRounds()
|
||
return
|
||
}
|
||
|
||
// 计算正确率
|
||
const accuracy = Math.round((correctCount.value / questions.value.length) * 100)
|
||
|
||
// 显示第一轮成绩并提示进入第二轮
|
||
const roundMessage = `第${currentRound.value}轮考试完成!\n\n` +
|
||
`📊 本轮得分:${score} 分(满分100分)\n` +
|
||
`✅ 答对:${correctCount.value} 题\n` +
|
||
`❌ 答错:${wrongCount.value} 题\n` +
|
||
`📈 正确率:${accuracy}%\n\n` +
|
||
`即将进入第${currentRound.value + 1}轮考试(错题重考)\n` +
|
||
`10秒后自动开始,或点击"立即开始"按钮`
|
||
|
||
// 开始下一轮错题考试
|
||
currentRound.value++
|
||
currentIndex.value = 0
|
||
correctCount.value = 0
|
||
wrongCount.value = 0
|
||
|
||
// 显示成绩并倒计时
|
||
let countdown = 10
|
||
const countdownInterval = setInterval(() => {
|
||
countdown--
|
||
}, 1000)
|
||
|
||
// 显示提示信息
|
||
ElMessageBox.alert(
|
||
roundMessage,
|
||
`第${currentRound.value - 1}轮考试结束`,
|
||
{
|
||
confirmButtonText: '立即开始第' + currentRound.value + '轮',
|
||
type: accuracy >= 80 ? 'success' : 'info',
|
||
showClose: false,
|
||
beforeClose: (_action, _instance, done) => {
|
||
clearInterval(countdownInterval)
|
||
done()
|
||
}
|
||
}
|
||
).then(async () => {
|
||
clearInterval(countdownInterval)
|
||
// 设置生成中状态
|
||
isGenerating.value = true
|
||
// 重新生成试题(基于上一轮的错题记录)
|
||
await loadQuestions()
|
||
})
|
||
|
||
// 10秒后自动开始
|
||
setTimeout(() => {
|
||
clearInterval(countdownInterval)
|
||
if (isGenerating.value) return // 如果已经开始,不重复触发
|
||
isGenerating.value = true
|
||
ElMessageBox.close()
|
||
loadQuestions()
|
||
}, 10000)
|
||
|
||
}
|
||
|
||
/**
|
||
* 完成当前轮次
|
||
*/
|
||
const finishRound = async () => {
|
||
const accuracy = Math.round((correctCount.value / questions.value.length) * 100)
|
||
roundScores.value[currentRound.value - 1] = accuracy
|
||
|
||
if (wrongCount.value === 0 || currentRound.value >= maxRounds.value) {
|
||
// 没有错题或达到最大轮次,结束考试
|
||
finishAllRounds()
|
||
} else {
|
||
// 继续下一轮错题重考
|
||
await startWrongQuestionRound()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 完成所有轮次
|
||
*/
|
||
const finishAllRounds = async () => {
|
||
// 保存最后一轮的得分到数据库
|
||
const currentScore = Math.round((correctCount.value / questions.value.length) * 100)
|
||
|
||
try {
|
||
await updateRoundScore({
|
||
exam_id: currentExamId.value,
|
||
round: currentRound.value,
|
||
score: currentScore,
|
||
is_final: true
|
||
})
|
||
console.log(`✅ 更新第${currentRound.value}轮得分成功: ${currentScore}`)
|
||
} catch (error: any) {
|
||
console.error('更新最后一轮得分失败:', error)
|
||
ElMessage.error('保存考试成绩失败,请重试')
|
||
}
|
||
|
||
// 计算平均得分
|
||
const avgScore = Math.round(roundScores.value.reduce((sum, score) => sum + score, 0) / roundScores.value.length)
|
||
|
||
// 如果第一轮就满分,特殊恭喜
|
||
if (currentRound.value === 1 && wrongCount.value === 0) {
|
||
ElMessageBox.alert(
|
||
`🎉 恭喜!考试满分!\n\n第1轮正确率:100%\n\n您的考试成绩已记录`,
|
||
'考试完成',
|
||
{
|
||
confirmButtonText: '返回课程中心',
|
||
type: 'success',
|
||
callback: () => {
|
||
router.push('/trainee/course-center')
|
||
}
|
||
}
|
||
)
|
||
return
|
||
}
|
||
|
||
// 多轮考试结果
|
||
let resultMessage = `考试完成!共进行了 ${currentRound.value} 轮考试\n\n`
|
||
|
||
roundScores.value.forEach((score, index) => {
|
||
resultMessage += `第${index + 1}轮正确率:${score}%\n`
|
||
})
|
||
|
||
resultMessage += `\n平均正确率:${avgScore}%\n\n您的考试成绩已记录`
|
||
|
||
ElMessageBox.alert(resultMessage, '考试结果', {
|
||
confirmButtonText: '返回课程中心',
|
||
type: avgScore >= 80 ? 'success' : 'info',
|
||
callback: () => {
|
||
router.push('/trainee/course-center')
|
||
}
|
||
})
|
||
}
|
||
|
||
// 已被finishAllRounds替代
|
||
// const finishPractice = () => {
|
||
// finishAllRounds()
|
||
// }
|
||
|
||
/**
|
||
* 获取题型标签类型
|
||
*/
|
||
const getQuestionTypeTag = (type: string) => {
|
||
const typeMap: Record<string, string> = {
|
||
single: 'primary',
|
||
multiple: 'success',
|
||
judge: 'warning',
|
||
blank: 'danger',
|
||
essay: 'info'
|
||
}
|
||
return typeMap[type] || 'primary'
|
||
}
|
||
|
||
/**
|
||
* 获取题型文本
|
||
*/
|
||
const getQuestionTypeText = (type: string) => {
|
||
const typeMap: Record<string, string> = {
|
||
single: '单选题',
|
||
multiple: '多选题',
|
||
judge: '判断题',
|
||
blank: '填空题',
|
||
essay: '问答题'
|
||
}
|
||
return typeMap[type] || ''
|
||
}
|
||
|
||
/**
|
||
* 渲染markdown内容
|
||
*/
|
||
const renderMarkdown = (content: string): string => {
|
||
if (!content) return ''
|
||
try {
|
||
// 配置marked选项:支持GFM(GitHub Flavored Markdown)
|
||
marked.setOptions({
|
||
breaks: true, // 支持GFM换行(单个换行符转为<br>)
|
||
gfm: true // 启用GitHub Flavored Markdown
|
||
// headerIds, mangle 等选项已废弃
|
||
})
|
||
|
||
const html = marked.parse(content) as string
|
||
return DOMPurify.sanitize(html)
|
||
} catch (error) {
|
||
console.error('Markdown渲染失败:', error)
|
||
return content
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 格式化正确答案
|
||
*/
|
||
const formatCorrectAnswer = (question: any) => {
|
||
if (question.type === 'single' || question.type === 'multiple') {
|
||
// 如果有correctAnswer字段(来自Dify的原始correct)
|
||
if (question.correctAnswer) {
|
||
// 多选题格式优化:将 "A:xxx,B:xxx" 改为换行显示
|
||
// 匹配 ",A:" 或 ",B:" 等格式,在逗号后的选项字母前换行
|
||
const formatted = question.correctAnswer.replace(/,([A-Za-z][::])/g, '\n$1')
|
||
return formatted
|
||
}
|
||
// 否则从options中提取
|
||
return question.options
|
||
.map((opt: any, idx: number) => opt.isCorrect ? String.fromCharCode(65 + idx) : null)
|
||
.filter((v: any) => v)
|
||
.join('、')
|
||
} else if (question.type === 'judge') {
|
||
// 优先使用correctAnswerText(Dify原始值:"正确"或"错误")
|
||
if (question.correctAnswerText) {
|
||
return question.correctAnswerText
|
||
}
|
||
// 备用:从boolean转换
|
||
return question.correctAnswer ? '正确' : '错误'
|
||
} else if (question.type === 'blank' || question.type === 'essay') {
|
||
return question.correctAnswer || ''
|
||
}
|
||
return ''
|
||
}
|
||
|
||
/**
|
||
* 保存答题进度
|
||
*/
|
||
const saveProgress = () => {
|
||
const progress = {
|
||
currentIndex: currentIndex.value,
|
||
currentRound: currentRound.value,
|
||
correctCount: correctCount.value,
|
||
wrongCount: wrongCount.value,
|
||
roundScores: roundScores.value,
|
||
wrongQuestions: wrongQuestions.value,
|
||
currentWrongIndex: currentWrongIndex.value,
|
||
isWrongQuestionMode: isWrongQuestionMode.value,
|
||
userAnswers: userAnswers.value,
|
||
timestamp: Date.now()
|
||
}
|
||
|
||
try {
|
||
localStorage.setItem(PRACTICE_STORAGE_KEY, JSON.stringify(progress))
|
||
} catch (error) {
|
||
console.warn('无法保存答题进度:', error)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 恢复答题进度
|
||
*/
|
||
// 组件挂载时初始化考试
|
||
onMounted(() => {
|
||
initExam()
|
||
})
|
||
|
||
// 页面卸载前清理
|
||
onUnmounted(() => {
|
||
// 可以在这里清理资源
|
||
})
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.practice-container {
|
||
max-width: 1200px;
|
||
margin: 0 auto;
|
||
|
||
// 加载状态
|
||
.loading-container {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 60vh;
|
||
padding: 60px 20px;
|
||
|
||
h3 {
|
||
margin: 24px 0 12px;
|
||
font-size: 20px;
|
||
font-weight: 600;
|
||
color: #333;
|
||
}
|
||
|
||
p {
|
||
font-size: 14px;
|
||
color: #666;
|
||
margin: 0;
|
||
}
|
||
}
|
||
|
||
// 无题目状态
|
||
.no-questions {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 60vh;
|
||
}
|
||
|
||
// 考试内容
|
||
.practice-content {
|
||
.practice-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 24px;
|
||
padding: 16px 24px;
|
||
background: #fff;
|
||
border-radius: 12px;
|
||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||
|
||
.header-info {
|
||
h2 {
|
||
font-size: 20px;
|
||
font-weight: 600;
|
||
color: #333;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.progress-info {
|
||
font-size: 14px;
|
||
color: #666;
|
||
|
||
.separator {
|
||
margin: 0 12px;
|
||
color: #ddd;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.practice-main {
|
||
.question-section {
|
||
margin-bottom: 24px;
|
||
padding: 32px;
|
||
|
||
.question-type {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 24px;
|
||
|
||
.question-score {
|
||
font-size: 14px;
|
||
color: #666;
|
||
}
|
||
}
|
||
|
||
.question-content {
|
||
h3 {
|
||
font-size: 18px;
|
||
font-weight: 500;
|
||
color: #333;
|
||
margin-bottom: 24px;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.options-list {
|
||
.option-item {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 16px 20px;
|
||
margin-bottom: 12px;
|
||
background: #f5f7fa;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
position: relative;
|
||
|
||
&:hover:not(.selected) {
|
||
background: #e6e8eb;
|
||
}
|
||
|
||
&.selected {
|
||
background: rgba(102, 126, 234, 0.1);
|
||
border: 1px solid #667eea;
|
||
}
|
||
|
||
&.correct {
|
||
background: rgba(103, 194, 58, 0.1);
|
||
border: 1px solid #67c23a;
|
||
}
|
||
|
||
&.wrong {
|
||
background: rgba(245, 108, 108, 0.1);
|
||
border: 1px solid #f56c6c;
|
||
}
|
||
|
||
.option-label {
|
||
width: 32px;
|
||
height: 32px;
|
||
background: #fff;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-weight: 600;
|
||
color: #333;
|
||
margin-right: 16px;
|
||
}
|
||
|
||
.option-content {
|
||
flex: 1;
|
||
font-size: 16px;
|
||
color: #333;
|
||
}
|
||
|
||
.result-icon {
|
||
position: absolute;
|
||
right: 20px;
|
||
font-size: 24px;
|
||
|
||
&.correct {
|
||
color: #67c23a;
|
||
}
|
||
|
||
&.wrong {
|
||
color: #f56c6c;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.judge-options {
|
||
.judge-cards {
|
||
display: flex;
|
||
gap: 20px;
|
||
justify-content: center;
|
||
|
||
.judge-card {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 140px;
|
||
height: 120px;
|
||
border: 2px solid #e4e7ed;
|
||
border-radius: 12px;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
background: #fff;
|
||
|
||
&:hover:not(.disabled) {
|
||
border-color: #409eff;
|
||
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.15);
|
||
}
|
||
|
||
&.active {
|
||
border-color: #409eff;
|
||
background: linear-gradient(135deg, #ecf5ff 0%, #f5f9ff 100%);
|
||
|
||
.judge-icon {
|
||
transform: scale(1.1);
|
||
}
|
||
}
|
||
|
||
&.disabled {
|
||
cursor: not-allowed;
|
||
opacity: 0.8;
|
||
}
|
||
|
||
&.correct {
|
||
border-color: #67c23a;
|
||
background: linear-gradient(135deg, #f0f9eb 0%, #f5faf2 100%);
|
||
|
||
.judge-text {
|
||
color: #67c23a;
|
||
}
|
||
}
|
||
|
||
&.wrong {
|
||
border-color: #f56c6c;
|
||
background: linear-gradient(135deg, #fef0f0 0%, #fdf5f5 100%);
|
||
|
||
.judge-text {
|
||
color: #f56c6c;
|
||
}
|
||
}
|
||
|
||
.judge-icon {
|
||
margin-bottom: 10px;
|
||
transition: transform 0.3s ease;
|
||
|
||
&.correct-icon {
|
||
color: #67c23a;
|
||
}
|
||
|
||
&.wrong-icon {
|
||
color: #f56c6c;
|
||
}
|
||
}
|
||
|
||
.judge-text {
|
||
font-size: 16px;
|
||
font-weight: 500;
|
||
color: #606266;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.blank-answer {
|
||
.blank-input-wrapper {
|
||
max-width: 500px;
|
||
|
||
.el-input {
|
||
:deep(.el-input__wrapper) {
|
||
padding: 8px 15px;
|
||
box-shadow: 0 0 0 1px #dcdfe6 inset;
|
||
border-radius: 8px;
|
||
transition: all 0.3s ease;
|
||
|
||
&:hover {
|
||
box-shadow: 0 0 0 1px #c0c4cc inset;
|
||
}
|
||
|
||
&.is-focus {
|
||
box-shadow: 0 0 0 1px #409eff inset;
|
||
}
|
||
}
|
||
|
||
:deep(.el-input__inner) {
|
||
font-size: 16px;
|
||
}
|
||
|
||
:deep(.el-input__prefix) {
|
||
color: #909399;
|
||
}
|
||
}
|
||
|
||
.blank-hint {
|
||
margin-top: 8px;
|
||
font-size: 12px;
|
||
color: #909399;
|
||
text-align: right;
|
||
}
|
||
}
|
||
}
|
||
|
||
.essay-answer {
|
||
:deep(.el-textarea__inner) {
|
||
font-size: 14px;
|
||
line-height: 1.6;
|
||
}
|
||
}
|
||
}
|
||
|
||
.answer-section {
|
||
margin-top: 24px;
|
||
|
||
.correct-feedback {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
padding: 20px;
|
||
background: rgba(103, 194, 58, 0.1);
|
||
border: 1px solid #67c23a;
|
||
border-radius: 12px;
|
||
animation: correctPulse 0.6s ease-in-out;
|
||
|
||
.feedback-icon {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.feedback-text {
|
||
h3 {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: #67c23a;
|
||
margin: 0 0 4px 0;
|
||
}
|
||
|
||
p {
|
||
font-size: 14px;
|
||
color: #67c23a;
|
||
margin: 0;
|
||
opacity: 0.8;
|
||
}
|
||
}
|
||
}
|
||
|
||
.answer-title {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: #333;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.correct-answer {
|
||
font-size: 14px;
|
||
color: #67c23a;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.answer-explanation {
|
||
font-size: 14px;
|
||
color: #666;
|
||
line-height: 1.6;
|
||
background: #f5f7fa;
|
||
padding: 12px 16px;
|
||
border-radius: 8px;
|
||
}
|
||
}
|
||
}
|
||
|
||
.submit-section {
|
||
display: flex;
|
||
justify-content: center;
|
||
margin-top: 32px;
|
||
|
||
.el-button {
|
||
min-width: 160px;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
border: none;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 动画效果
|
||
@keyframes correctPulse {
|
||
0% {
|
||
transform: scale(0.95);
|
||
opacity: 0;
|
||
}
|
||
50% {
|
||
transform: scale(1.02);
|
||
}
|
||
100% {
|
||
transform: scale(1);
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
// 答案解析弹窗样式
|
||
:deep(.answer-dialog) {
|
||
.el-dialog {
|
||
margin-top: 15vh !important;
|
||
margin-bottom: auto;
|
||
}
|
||
|
||
.el-dialog__body {
|
||
padding: 10px 20px;
|
||
max-height: none;
|
||
overflow: visible;
|
||
}
|
||
}
|
||
|
||
.answer-dialog-content {
|
||
.result-section {
|
||
text-align: center;
|
||
margin-bottom: 16px;
|
||
|
||
.result-icon-large {
|
||
width: 64px;
|
||
height: 64px;
|
||
margin: 0 auto 10px;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
|
||
&.wrong {
|
||
background: rgba(245, 108, 108, 0.1);
|
||
color: #f56c6c;
|
||
}
|
||
}
|
||
|
||
.result-text {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: #f56c6c;
|
||
margin: 0;
|
||
}
|
||
}
|
||
|
||
.dialog-scroll-area {
|
||
max-height: 60vh;
|
||
overflow-y: auto;
|
||
|
||
&::-webkit-scrollbar {
|
||
width: 6px;
|
||
}
|
||
|
||
&::-webkit-scrollbar-thumb {
|
||
background: #dcdfe6;
|
||
border-radius: 3px;
|
||
}
|
||
|
||
&::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
}
|
||
|
||
.question-review,
|
||
.answer-review,
|
||
.explanation-review {
|
||
margin-bottom: 16px;
|
||
|
||
h4 {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #333;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
p {
|
||
font-size: 14px;
|
||
line-height: 1.6;
|
||
margin: 0;
|
||
color: #666;
|
||
}
|
||
|
||
.correct-answer-text {
|
||
font-size: 14px;
|
||
line-height: 1.6;
|
||
color: #333;
|
||
background: #f0f9ff;
|
||
border-left: 4px solid #67c23a;
|
||
padding: 10px 14px;
|
||
border-radius: 6px;
|
||
|
||
strong {
|
||
color: #333;
|
||
font-weight: 600;
|
||
}
|
||
}
|
||
|
||
.explanation-text {
|
||
font-size: 13px;
|
||
line-height: 1.6;
|
||
color: #666;
|
||
background: #f5f7fa;
|
||
padding: 12px;
|
||
border-radius: 8px;
|
||
|
||
strong {
|
||
color: #333;
|
||
font-weight: 600;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Markdown内容样式
|
||
.markdown-content {
|
||
line-height: 1.8;
|
||
|
||
p {
|
||
margin: 8px 0;
|
||
}
|
||
|
||
h1, h2, h3, h4, h5, h6 {
|
||
margin: 16px 0 8px 0;
|
||
font-weight: 600;
|
||
}
|
||
|
||
ul, ol {
|
||
margin: 8px 0 8px 8px; // 增加左边距,避免压到绿边
|
||
padding-left: 20px;
|
||
}
|
||
|
||
li {
|
||
margin: 4px 0;
|
||
padding-left: 4px; // 列表项增加左内边距
|
||
}
|
||
|
||
code {
|
||
background: rgba(0, 0, 0, 0.05);
|
||
padding: 2px 6px;
|
||
border-radius: 4px;
|
||
font-family: 'Monaco', 'Courier New', monospace;
|
||
font-size: 13px;
|
||
}
|
||
|
||
pre {
|
||
background: rgba(0, 0, 0, 0.05);
|
||
padding: 12px;
|
||
border-radius: 8px;
|
||
overflow-x: auto;
|
||
|
||
code {
|
||
background: none;
|
||
padding: 0;
|
||
}
|
||
}
|
||
|
||
blockquote {
|
||
border-left: 4px solid #409eff;
|
||
padding-left: 16px;
|
||
margin: 12px 0;
|
||
color: #666;
|
||
}
|
||
|
||
strong, b {
|
||
font-weight: 700 !important;
|
||
color: #333 !important;
|
||
}
|
||
|
||
em, i {
|
||
font-style: italic !important;
|
||
}
|
||
|
||
a {
|
||
color: #409eff;
|
||
text-decoration: none;
|
||
|
||
&:hover {
|
||
text-decoration: underline;
|
||
}
|
||
}
|
||
}
|
||
|
||
.dialog-footer {
|
||
text-align: center;
|
||
|
||
.el-button {
|
||
min-width: 120px;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
border: none;
|
||
}
|
||
}
|
||
|
||
// 响应式
|
||
@media (max-width: 768px) {
|
||
.practice-container {
|
||
padding: 0 12px;
|
||
|
||
.practice-header {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
gap: 16px;
|
||
padding: 12px 16px;
|
||
|
||
.el-button {
|
||
width: 100%;
|
||
}
|
||
}
|
||
|
||
.question-section {
|
||
padding: 16px !important;
|
||
|
||
.option-item {
|
||
padding: 12px 16px !important;
|
||
|
||
.option-label {
|
||
width: 28px !important;
|
||
height: 28px !important;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.option-content {
|
||
font-size: 14px !important;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 修复"确认选择"按钮 - 确保完全可见且易于点击
|
||
.submit-section {
|
||
margin-top: 16px !important;
|
||
padding: 0 8px 24px; // 底部增加安全距离
|
||
position: sticky;
|
||
bottom: 0;
|
||
background: linear-gradient(to top, #f5f7fa 80%, transparent);
|
||
padding-top: 20px;
|
||
z-index: 10;
|
||
|
||
.el-button {
|
||
width: 100%;
|
||
min-width: 0 !important;
|
||
min-height: 48px; // 增大按钮高度,便于手机点击
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
border-radius: 24px;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 修复"记住了"按钮所在弹窗的移动端显示
|
||
.answer-dialog-content {
|
||
.result-section {
|
||
.result-icon-large {
|
||
width: 60px;
|
||
height: 60px;
|
||
|
||
.el-icon {
|
||
font-size: 36px !important;
|
||
}
|
||
}
|
||
|
||
.result-text {
|
||
font-size: 18px;
|
||
}
|
||
}
|
||
|
||
.question-review,
|
||
.answer-review,
|
||
.explanation-review {
|
||
margin-bottom: 16px;
|
||
|
||
h4 {
|
||
font-size: 14px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
p, .correct-answer-text, .explanation-text {
|
||
font-size: 13px;
|
||
line-height: 1.6;
|
||
}
|
||
}
|
||
}
|
||
|
||
.dialog-footer {
|
||
padding: 16px 20px 20px !important;
|
||
|
||
.el-button {
|
||
width: 100% !important;
|
||
min-width: 0 !important;
|
||
min-height: 48px; // 增大按钮高度,便于手机点击
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
border-radius: 24px;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 移动端弹窗全局样式覆盖(需要穿透到 el-dialog)
|
||
:deep(.el-dialog) {
|
||
@media (max-width: 768px) {
|
||
width: 92% !important;
|
||
max-width: none !important;
|
||
margin: 10vh auto !important;
|
||
|
||
.el-dialog__header {
|
||
padding: 16px 20px 12px;
|
||
|
||
.el-dialog__title {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
}
|
||
}
|
||
|
||
.el-dialog__body {
|
||
padding: 12px 16px;
|
||
max-height: 60vh;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.el-dialog__footer {
|
||
padding: 12px 16px 20px;
|
||
}
|
||
}
|
||
}
|
||
</style> |