721 lines
17 KiB
Vue
721 lines
17 KiB
Vue
<template>
|
||
<view class="exam-page">
|
||
<!-- 考试信息 -->
|
||
<view class="exam-header" v-if="!examStarted">
|
||
<view class="exam-title">{{ examInfo.examName }}</view>
|
||
<view class="exam-info">
|
||
<view class="info-item">
|
||
<text class="label">考试时长:</text>
|
||
<text class="value">{{ examInfo.duration }}分钟</text>
|
||
</view>
|
||
<view class="info-item">
|
||
<text class="label">总分:</text>
|
||
<text class="value">{{ examInfo.totalScore }}分</text>
|
||
</view>
|
||
<view class="info-item">
|
||
<text class="label">及格分:</text>
|
||
<text class="value">{{ examInfo.passScore }}分</text>
|
||
</view>
|
||
<view class="info-item">
|
||
<text class="label">题目数量:</text>
|
||
<text class="value">{{ examInfo.questionCount }}题</text>
|
||
</view>
|
||
</view>
|
||
<view class="exam-description">{{ examInfo.description }}</view>
|
||
<button class="btn-start" @click="startExam">开始考试</button>
|
||
</view>
|
||
|
||
<!-- 考试进行中 -->
|
||
<view class="exam-content" v-if="examStarted && !examFinished">
|
||
<!-- 倒计时 -->
|
||
<view class="timer-bar">
|
||
<text class="timer-text">剩余时间:{{ formatTime(remainingTime) }}</text>
|
||
<text class="question-progress">{{ currentQuestionIndex + 1 }}/{{ questions.length }}</text>
|
||
</view>
|
||
|
||
<!-- 题目 -->
|
||
<view class="question-card">
|
||
<view class="question-header">
|
||
<text class="question-type">{{ getQuestionType(currentQuestion.questionType) }}</text>
|
||
<text class="question-score">{{ currentQuestion.score }}分</text>
|
||
</view>
|
||
<view class="question-content">{{ currentQuestion.questionContent }}</view>
|
||
|
||
<!-- 选项 -->
|
||
<view class="options">
|
||
<view
|
||
v-for="(option, index) in currentQuestion.options"
|
||
:key="index"
|
||
class="option-item"
|
||
:class="{ 'selected': isOptionSelected(option.key) }"
|
||
@click="selectOption(option.key)"
|
||
>
|
||
<view class="option-key">{{ option.key }}</view>
|
||
<view class="option-text">{{ option.value }}</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 填空题输入框 -->
|
||
<view v-if="currentQuestion.questionType === 'fill'" class="fill-input">
|
||
<textarea
|
||
v-model="answers[currentQuestion.id]"
|
||
placeholder="请输入答案"
|
||
class="textarea"
|
||
></textarea>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 导航按钮 -->
|
||
<view class="nav-buttons">
|
||
<button class="btn-prev" @click="prevQuestion" :disabled="currentQuestionIndex === 0">上一题</button>
|
||
<button class="btn-next" @click="nextQuestion" v-if="currentQuestionIndex < questions.length - 1">下一题</button>
|
||
<button class="btn-submit" @click="confirmSubmit" v-else>提交答卷</button>
|
||
</view>
|
||
|
||
<!-- 答题卡 -->
|
||
<view class="answer-sheet">
|
||
<view class="sheet-title">答题卡</view>
|
||
<view class="sheet-grid">
|
||
<view
|
||
v-for="(question, index) in questions"
|
||
:key="index"
|
||
class="sheet-item"
|
||
:class="{
|
||
'current': index === currentQuestionIndex,
|
||
'answered': answers[question.id]
|
||
}"
|
||
@click="jumpToQuestion(index)"
|
||
>
|
||
{{ index + 1 }}
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 考试结果 -->
|
||
<view class="exam-result" v-if="examFinished">
|
||
<view class="result-icon">{{ examResult.isPassed ? '🎉' : '😢' }}</view>
|
||
<view class="result-title">{{ examResult.isPassed ? '恭喜通过考试!' : '很遗憾,未通过考试' }}</view>
|
||
<view class="result-score">
|
||
<text class="score-value">{{ examResult.totalScore }}</text>
|
||
<text class="score-label">分</text>
|
||
</view>
|
||
<view class="result-stats">
|
||
<view class="stat-item">
|
||
<text class="stat-value">{{ examResult.correctCount }}</text>
|
||
<text class="stat-label">答对</text>
|
||
</view>
|
||
<view class="stat-item">
|
||
<text class="stat-value">{{ examResult.totalCount - examResult.correctCount }}</text>
|
||
<text class="stat-label">答错</text>
|
||
</view>
|
||
<view class="stat-item">
|
||
<text class="stat-value">{{ examResult.passScore }}</text>
|
||
<text class="stat-label">及格分</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 答题详情 -->
|
||
<view class="answer-details">
|
||
<view class="details-title">答题详情</view>
|
||
<view
|
||
v-for="(result, index) in examResult.results"
|
||
:key="index"
|
||
class="detail-item"
|
||
:class="{ 'correct': result.isCorrect, 'wrong': !result.isCorrect }"
|
||
>
|
||
<view class="detail-header">
|
||
<text class="detail-index">第{{ index + 1 }}题</text>
|
||
<text class="detail-score">{{ result.isCorrect ? '✓' : '✗' }} {{ result.score }}分</text>
|
||
</view>
|
||
<view class="detail-answer">
|
||
<text class="answer-label">你的答案:</text>
|
||
<text class="answer-value">{{ result.userAnswer || '未作答' }}</text>
|
||
</view>
|
||
<view class="detail-answer" v-if="!result.isCorrect">
|
||
<text class="answer-label">正确答案:</text>
|
||
<text class="answer-value correct-answer">{{ result.correctAnswer }}</text>
|
||
</view>
|
||
<view class="detail-explanation" v-if="result.explanation">
|
||
<text class="explanation-label">解析:</text>
|
||
<text class="explanation-text">{{ result.explanation }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="result-buttons">
|
||
<button class="btn-back" @click="goBack">返回</button>
|
||
<button class="btn-retry" @click="retryExam" v-if="!examResult.isPassed">重新考试</button>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
export default {
|
||
data() {
|
||
return {
|
||
examType: '',
|
||
level: '',
|
||
examInfo: {},
|
||
examStarted: false,
|
||
examFinished: false,
|
||
questions: [],
|
||
currentQuestionIndex: 0,
|
||
answers: {},
|
||
remainingTime: 0,
|
||
timer: null,
|
||
examResult: null
|
||
};
|
||
},
|
||
computed: {
|
||
currentQuestion() {
|
||
return this.questions[this.currentQuestionIndex] || {};
|
||
}
|
||
},
|
||
onLoad(options) {
|
||
this.examType = options.courseType;
|
||
this.level = options.level;
|
||
this.loadExamInfo();
|
||
},
|
||
onUnload() {
|
||
if (this.timer) {
|
||
clearInterval(this.timer);
|
||
}
|
||
},
|
||
methods: {
|
||
// 加载考试信息
|
||
async loadExamInfo() {
|
||
try {
|
||
const res = await this.$http.get('/api/training/exams', {
|
||
params: { examType: this.examType }
|
||
});
|
||
this.examInfo = res.data.find(exam => exam.level === this.level) || res.data[0];
|
||
} catch (error) {
|
||
uni.showToast({ title: '加载失败', icon: 'none' });
|
||
}
|
||
},
|
||
|
||
// 开始考试
|
||
async startExam() {
|
||
try {
|
||
const res = await this.$http.post('/api/training/start-exam', {
|
||
examId: this.examInfo.id
|
||
});
|
||
this.questions = res.data.questions.map(q => ({
|
||
...q,
|
||
options: JSON.parse(q.options)
|
||
}));
|
||
this.remainingTime = this.examInfo.duration * 60;
|
||
this.examStarted = true;
|
||
this.startTimer();
|
||
} catch (error) {
|
||
uni.showToast({ title: error.message || '开始考试失败', icon: 'none' });
|
||
}
|
||
},
|
||
|
||
// 开始计时
|
||
startTimer() {
|
||
this.timer = setInterval(() => {
|
||
this.remainingTime--;
|
||
if (this.remainingTime <= 0) {
|
||
this.submitExam();
|
||
}
|
||
}, 1000);
|
||
},
|
||
|
||
// 格式化时间
|
||
formatTime(seconds) {
|
||
const minutes = Math.floor(seconds / 60);
|
||
const secs = seconds % 60;
|
||
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||
},
|
||
|
||
// 获取题目类型
|
||
getQuestionType(type) {
|
||
const types = {
|
||
single: '单选题',
|
||
multiple: '多选题',
|
||
judge: '判断题',
|
||
fill: '填空题'
|
||
};
|
||
return types[type] || '';
|
||
},
|
||
|
||
// 选择选项
|
||
selectOption(key) {
|
||
const questionId = this.currentQuestion.id;
|
||
const questionType = this.currentQuestion.questionType;
|
||
|
||
if (questionType === 'multiple') {
|
||
// 多选题
|
||
let selected = this.answers[questionId] || '';
|
||
if (selected.includes(key)) {
|
||
selected = selected.replace(key, '');
|
||
} else {
|
||
selected += key;
|
||
}
|
||
this.$set(this.answers, questionId, selected.split('').sort().join(''));
|
||
} else {
|
||
// 单选题和判断题
|
||
this.$set(this.answers, questionId, key);
|
||
}
|
||
},
|
||
|
||
// 判断选项是否被选中
|
||
isOptionSelected(key) {
|
||
const answer = this.answers[this.currentQuestion.id] || '';
|
||
return answer.includes(key);
|
||
},
|
||
|
||
// 上一题
|
||
prevQuestion() {
|
||
if (this.currentQuestionIndex > 0) {
|
||
this.currentQuestionIndex--;
|
||
}
|
||
},
|
||
|
||
// 下一题
|
||
nextQuestion() {
|
||
if (this.currentQuestionIndex < this.questions.length - 1) {
|
||
this.currentQuestionIndex++;
|
||
}
|
||
},
|
||
|
||
// 跳转到指定题目
|
||
jumpToQuestion(index) {
|
||
this.currentQuestionIndex = index;
|
||
},
|
||
|
||
// 确认提交
|
||
confirmSubmit() {
|
||
const unanswered = this.questions.filter(q => !this.answers[q.id]).length;
|
||
if (unanswered > 0) {
|
||
uni.showModal({
|
||
title: '提示',
|
||
content: `还有${unanswered}题未作答,确定要提交吗?`,
|
||
success: (res) => {
|
||
if (res.confirm) {
|
||
this.submitExam();
|
||
}
|
||
}
|
||
});
|
||
} else {
|
||
this.submitExam();
|
||
}
|
||
},
|
||
|
||
// 提交考试
|
||
async submitExam() {
|
||
if (this.timer) {
|
||
clearInterval(this.timer);
|
||
}
|
||
|
||
try {
|
||
const res = await this.$http.post('/api/training/submit-exam', {
|
||
examId: this.examInfo.id,
|
||
answers: this.answers
|
||
});
|
||
this.examResult = res.data;
|
||
this.examFinished = true;
|
||
} catch (error) {
|
||
uni.showToast({ title: '提交失败', icon: 'none' });
|
||
}
|
||
},
|
||
|
||
// 重新考试
|
||
retryExam() {
|
||
this.examStarted = false;
|
||
this.examFinished = false;
|
||
this.currentQuestionIndex = 0;
|
||
this.answers = {};
|
||
this.examResult = null;
|
||
},
|
||
|
||
// 返回
|
||
goBack() {
|
||
uni.navigateBack();
|
||
}
|
||
}
|
||
};
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.exam-page {
|
||
min-height: 100vh;
|
||
background: #f5f5f5;
|
||
}
|
||
|
||
.exam-header {
|
||
background: #fff;
|
||
padding: 40rpx;
|
||
text-align: center;
|
||
|
||
.exam-title {
|
||
font-size: 40rpx;
|
||
font-weight: bold;
|
||
color: #333;
|
||
margin-bottom: 30rpx;
|
||
}
|
||
|
||
.exam-info {
|
||
margin-bottom: 30rpx;
|
||
|
||
.info-item {
|
||
display: flex;
|
||
justify-content: center;
|
||
margin-bottom: 20rpx;
|
||
font-size: 28rpx;
|
||
|
||
.label {
|
||
color: #999;
|
||
}
|
||
|
||
.value {
|
||
color: #333;
|
||
font-weight: bold;
|
||
}
|
||
}
|
||
}
|
||
|
||
.exam-description {
|
||
font-size: 26rpx;
|
||
color: #666;
|
||
line-height: 1.6;
|
||
margin-bottom: 40rpx;
|
||
}
|
||
|
||
.btn-start {
|
||
width: 400rpx;
|
||
height: 88rpx;
|
||
background: linear-gradient(135deg, #7dd3c0, #5bc0ad);
|
||
color: #fff;
|
||
border-radius: 44rpx;
|
||
font-size: 32rpx;
|
||
border: none;
|
||
}
|
||
}
|
||
|
||
.exam-content {
|
||
padding: 20rpx;
|
||
}
|
||
|
||
.timer-bar {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
background: #fff;
|
||
padding: 20rpx 30rpx;
|
||
border-radius: 12rpx;
|
||
margin-bottom: 20rpx;
|
||
|
||
.timer-text {
|
||
font-size: 28rpx;
|
||
color: #ff4d4f;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.question-progress {
|
||
font-size: 28rpx;
|
||
color: #999;
|
||
}
|
||
}
|
||
|
||
.question-card {
|
||
background: #fff;
|
||
padding: 30rpx;
|
||
border-radius: 12rpx;
|
||
margin-bottom: 20rpx;
|
||
|
||
.question-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
margin-bottom: 20rpx;
|
||
|
||
.question-type {
|
||
font-size: 24rpx;
|
||
color: #7dd3c0;
|
||
padding: 8rpx 16rpx;
|
||
background: rgba(125, 211, 192, 0.1);
|
||
border-radius: 8rpx;
|
||
}
|
||
|
||
.question-score {
|
||
font-size: 24rpx;
|
||
color: #ff4d4f;
|
||
}
|
||
}
|
||
|
||
.question-content {
|
||
font-size: 30rpx;
|
||
color: #333;
|
||
line-height: 1.6;
|
||
margin-bottom: 30rpx;
|
||
}
|
||
|
||
.options {
|
||
.option-item {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 20rpx;
|
||
background: #f5f5f5;
|
||
border-radius: 12rpx;
|
||
margin-bottom: 20rpx;
|
||
border: 2rpx solid transparent;
|
||
|
||
&.selected {
|
||
background: rgba(125, 211, 192, 0.1);
|
||
border-color: #7dd3c0;
|
||
}
|
||
|
||
.option-key {
|
||
width: 60rpx;
|
||
height: 60rpx;
|
||
line-height: 60rpx;
|
||
text-align: center;
|
||
background: #fff;
|
||
border-radius: 50%;
|
||
font-size: 28rpx;
|
||
color: #333;
|
||
margin-right: 20rpx;
|
||
}
|
||
|
||
.option-text {
|
||
flex: 1;
|
||
font-size: 28rpx;
|
||
color: #333;
|
||
}
|
||
}
|
||
}
|
||
|
||
.fill-input {
|
||
.textarea {
|
||
width: 100%;
|
||
min-height: 200rpx;
|
||
padding: 20rpx;
|
||
background: #f5f5f5;
|
||
border-radius: 12rpx;
|
||
font-size: 28rpx;
|
||
}
|
||
}
|
||
}
|
||
|
||
.nav-buttons {
|
||
display: flex;
|
||
gap: 20rpx;
|
||
margin-bottom: 20rpx;
|
||
|
||
button {
|
||
flex: 1;
|
||
height: 88rpx;
|
||
border-radius: 44rpx;
|
||
font-size: 32rpx;
|
||
border: none;
|
||
}
|
||
|
||
.btn-prev {
|
||
background: #f5f5f5;
|
||
color: #333;
|
||
|
||
&[disabled] {
|
||
opacity: 0.5;
|
||
}
|
||
}
|
||
|
||
.btn-next {
|
||
background: linear-gradient(135deg, #7dd3c0, #5bc0ad);
|
||
color: #fff;
|
||
}
|
||
|
||
.btn-submit {
|
||
background: linear-gradient(135deg, #ff6b6b, #ff4d4f);
|
||
color: #fff;
|
||
}
|
||
}
|
||
|
||
.answer-sheet {
|
||
background: #fff;
|
||
padding: 30rpx;
|
||
border-radius: 12rpx;
|
||
|
||
.sheet-title {
|
||
font-size: 28rpx;
|
||
color: #333;
|
||
margin-bottom: 20rpx;
|
||
}
|
||
|
||
.sheet-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(5, 1fr);
|
||
gap: 20rpx;
|
||
|
||
.sheet-item {
|
||
height: 80rpx;
|
||
line-height: 80rpx;
|
||
text-align: center;
|
||
background: #f5f5f5;
|
||
border-radius: 12rpx;
|
||
font-size: 28rpx;
|
||
color: #333;
|
||
|
||
&.current {
|
||
background: #7dd3c0;
|
||
color: #fff;
|
||
}
|
||
|
||
&.answered {
|
||
background: rgba(125, 211, 192, 0.3);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.exam-result {
|
||
background: #fff;
|
||
padding: 40rpx;
|
||
text-align: center;
|
||
|
||
.result-icon {
|
||
font-size: 120rpx;
|
||
margin-bottom: 20rpx;
|
||
}
|
||
|
||
.result-title {
|
||
font-size: 36rpx;
|
||
font-weight: bold;
|
||
color: #333;
|
||
margin-bottom: 30rpx;
|
||
}
|
||
|
||
.result-score {
|
||
margin-bottom: 40rpx;
|
||
|
||
.score-value {
|
||
font-size: 80rpx;
|
||
font-weight: bold;
|
||
color: #7dd3c0;
|
||
}
|
||
|
||
.score-label {
|
||
font-size: 32rpx;
|
||
color: #999;
|
||
margin-left: 10rpx;
|
||
}
|
||
}
|
||
|
||
.result-stats {
|
||
display: flex;
|
||
justify-content: space-around;
|
||
margin-bottom: 40rpx;
|
||
|
||
.stat-item {
|
||
text-align: center;
|
||
|
||
.stat-value {
|
||
display: block;
|
||
font-size: 48rpx;
|
||
font-weight: bold;
|
||
color: #333;
|
||
margin-bottom: 10rpx;
|
||
}
|
||
|
||
.stat-label {
|
||
font-size: 24rpx;
|
||
color: #999;
|
||
}
|
||
}
|
||
}
|
||
|
||
.answer-details {
|
||
text-align: left;
|
||
margin-top: 40rpx;
|
||
|
||
.details-title {
|
||
font-size: 32rpx;
|
||
font-weight: bold;
|
||
color: #333;
|
||
margin-bottom: 20rpx;
|
||
}
|
||
|
||
.detail-item {
|
||
padding: 20rpx;
|
||
background: #f5f5f5;
|
||
border-radius: 12rpx;
|
||
margin-bottom: 20rpx;
|
||
border-left: 4rpx solid #7dd3c0;
|
||
|
||
&.wrong {
|
||
border-left-color: #ff4d4f;
|
||
}
|
||
|
||
.detail-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
margin-bottom: 15rpx;
|
||
|
||
.detail-index {
|
||
font-size: 28rpx;
|
||
color: #333;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.detail-score {
|
||
font-size: 28rpx;
|
||
}
|
||
}
|
||
|
||
.detail-answer {
|
||
font-size: 26rpx;
|
||
color: #666;
|
||
margin-bottom: 10rpx;
|
||
|
||
.answer-label {
|
||
color: #999;
|
||
}
|
||
|
||
.correct-answer {
|
||
color: #52c41a;
|
||
}
|
||
}
|
||
|
||
.detail-explanation {
|
||
font-size: 24rpx;
|
||
color: #999;
|
||
line-height: 1.6;
|
||
margin-top: 15rpx;
|
||
padding-top: 15rpx;
|
||
border-top: 1rpx solid #e0e0e0;
|
||
|
||
.explanation-label {
|
||
font-weight: bold;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.result-buttons {
|
||
display: flex;
|
||
gap: 20rpx;
|
||
margin-top: 40rpx;
|
||
|
||
button {
|
||
flex: 1;
|
||
height: 88rpx;
|
||
border-radius: 44rpx;
|
||
font-size: 32rpx;
|
||
border: none;
|
||
}
|
||
|
||
.btn-back {
|
||
background: #f5f5f5;
|
||
color: #333;
|
||
}
|
||
|
||
.btn-retry {
|
||
background: linear-gradient(135deg, #7dd3c0, #5bc0ad);
|
||
color: #fff;
|
||
}
|
||
}
|
||
}
|
||
</style>
|