guoyu/fronted_uniapp/pages/exam/detail.vue
2025-12-06 14:53:35 +08:00

1076 lines
34 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="exam-detail-container">
<!-- 顶部信息栏 -->
<view class="exam-header">
<view class="header-info">
<text class="exam-name">{{ examInfo.examName || '加载中...' }}</text>
<text class="exam-subject" v-if="examInfo.subjectName">{{ examInfo.subjectName }}</text>
</view>
<view class="timer-section">
<text class="timer-label">剩余时间</text>
<text class="timer-value" :class="{ 'timer-warning': remainingTime < 300 }">
{{ formatTime(remainingTime) }}
</text>
</view>
</view>
<!-- 答题卡 -->
<view class="answer-sheet" v-if="showAnswerSheet">
<view class="sheet-header">
<text class="sheet-title">答题卡</text>
<text class="close-btn" @click="showAnswerSheet = false">×</text>
</view>
<view class="sheet-content">
<view
v-for="(question, index) in questions"
:key="question.id"
class="sheet-item"
:class="{
'answered': answers[question.id] !== undefined && answers[question.id] !== '',
'current': currentIndex === index
}"
@click="jumpToQuestion(index)"
>
{{ index + 1 }}
</view>
</view>
</view>
<!-- 题目区域 -->
<scroll-view
class="questions-container"
scroll-y
:scroll-into-view="'question-' + currentIndex"
scroll-with-animation
>
<view
v-for="(question, index) in questions"
:key="question.id"
:id="'question-' + index"
class="question-item"
>
<view class="question-header">
<text class="question-number">第 {{ index + 1 }} 题</text>
<text class="question-score">{{ question.score }}分</text>
</view>
<view class="question-content">
<text class="question-text">
{{ question.questionContent }}
<text v-if="question.questionType === 'multiple'" class="multiple-tip">(可多选)</text>
</text>
</view>
<!-- 单选题 -->
<view v-if="question.questionType === 'single'" class="options-list">
<view
v-for="(option, optIndex) in parseOptions(question.options)"
:key="optIndex"
class="option-item"
:class="{ 'selected': answers[question.id] === option }"
@click="selectAnswer(question.id, option)"
>
<view class="option-radio">
<view class="radio-dot" v-if="answers[question.id] === option"></view>
</view>
<text class="option-label">{{ getOptionLabel(optIndex) }}</text>
<text class="option-text">{{ option }}</text>
</view>
</view>
<!-- 多选题 -->
<view v-if="question.questionType === 'multiple'">
<view class="multiple-hint">💡 提示:本题为多选题,可以选择多个答案</view>
<view class="options-list">
<view
v-for="(option, optIndex) in parseOptions(question.options)"
:key="optIndex"
class="option-item"
:class="{ 'selected': isOptionSelected(question.id, option) }"
@click="toggleMultipleAnswer(question.id, option)"
>
<view class="option-checkbox">
<text class="checkbox-icon" v-if="isOptionSelected(question.id, option)">✓</text>
</view>
<text class="option-label">{{ getOptionLabel(optIndex) }}</text>
<text class="option-text">{{ option }}</text>
</view>
</view>
</view>
<!-- 判断题 -->
<view v-if="question.questionType === 'judge'" class="options-list">
<view
class="option-item"
:class="{ 'selected': answers[question.id] === '正确' }"
@click="selectAnswer(question.id, '正确')"
>
<view class="option-radio">
<view class="radio-dot" v-if="answers[question.id] === '正确'"></view>
</view>
<text class="option-text">正确</text>
</view>
<view
class="option-item"
:class="{ 'selected': answers[question.id] === '错误' }"
@click="selectAnswer(question.id, '错误')"
>
<view class="option-radio">
<view class="radio-dot" v-if="answers[question.id] === '错误'"></view>
</view>
<text class="option-text">错误</text>
</view>
</view>
<!-- 填空题 -->
<view v-if="question.questionType === 'fill'" class="fill-answer">
<textarea
v-model="answers[question.id]"
placeholder="请输入答案"
class="fill-input"
@blur="saveAnswers"
></textarea>
</view>
<!-- 简答题 -->
<view v-if="question.questionType === 'essay'" class="essay-answer">
<textarea
v-model="answers[question.id]"
placeholder="请输入答案"
class="essay-input"
:auto-height="true"
@blur="saveAnswers"
></textarea>
</view>
</view>
</scroll-view>
<!-- 底部操作栏 -->
<view class="bottom-actions">
<button class="btn-sheet" @click="showAnswerSheet = !showAnswerSheet">答题卡</button>
<button
class="btn-prev"
:disabled="currentIndex === 0"
@click="prevQuestion"
>上一题</button>
<button
class="btn-next"
:disabled="currentIndex === questions.length - 1"
@click="nextQuestion"
>下一题</button>
<button class="btn-submit" @click="showSubmitModal = true">提交</button>
</view>
<!-- 提交确认弹窗 -->
<view class="submit-modal" v-if="showSubmitModal" @click.stop>
<view class="modal-mask" @click="showSubmitModal = false"></view>
<view class="modal-content">
<view class="modal-header">
<text class="modal-title">确认提交</text>
</view>
<view class="modal-body">
<text class="modal-text">确定要提交答案吗?提交后将无法修改。</text>
<view class="answer-stats">
<text class="stats-item">已答题:{{ answeredCount }}/{{ questions.length }}</text>
<text class="stats-item">未答题:{{ unansweredCount }}</text>
</view>
</view>
<view class="modal-footer">
<button class="btn-cancel" @click="showSubmitModal = false">取消</button>
<button class="btn-confirm" @click="submitExam">确认提交</button>
</view>
</view>
</view>
<!-- 退出确认弹窗 -->
<view class="exit-modal" v-if="showExitModal" @click.stop>
<view class="modal-mask" @click="showExitModal = false"></view>
<view class="modal-content">
<view class="modal-header">
<text class="modal-title">退出考试</text>
</view>
<view class="modal-body">
<view class="answer-stats">
<text class="stats-item">已答题:{{ answeredCount }}/{{ questions.length }}</text>
<text class="stats-item">未答题:{{ unansweredCount }}</text>
</view>
</view>
<view class="modal-footer">
<button class="btn-cancel" @click="showExitModal = false">取消</button>
<button class="btn-confirm-exit" @click="confirmExit">确定退出</button>
</view>
</view>
</view>
</view>
</template>
<script>
import { getExamQuestions, submitExamAnswer } from '@/api/study/exam.js'
export default {
data() {
return {
examId: null,
examInfo: {},
questions: [],
answers: {},
currentIndex: 0,
loading: false,
showAnswerSheet: false,
showSubmitModal: false,
showExitModal: false,
isExiting: false, // 标记是否正在退出
// 倒计时相关
remainingTime: 0,
timer: null,
startTime: null,
duration: 0
}
},
computed: {
answeredCount() {
// 只计算当前考试题目的答案数量,避免包含旧题目的答案
return this.questions.filter(question => {
const answer = this.answers[question.id]
return answer !== undefined && answer !== '' && answer !== null
}).length
},
unansweredCount() {
return this.questions.length - this.answeredCount
}
},
onLoad(options) {
if (options.id) {
this.examId = options.id
this.loadExamData()
} else {
uni.showToast({
title: '缺少考试ID',
icon: 'none'
})
setTimeout(() => {
uni.navigateBack()
}, 1500)
}
},
onBackPress() {
// 如果用户已确认退出,不拦截
if (this.isExiting) {
return false
}
// 拦截返回按钮,显示退出确认弹窗
if (this.answeredCount > 0) {
this.showExitModal = true
return true // 阻止默认返回行为
}
return false
},
onUnload() {
this.clearTimer()
// 保存答案到本地(退出时也会保存)
this.saveAnswers()
},
onHide() {
// 页面隐藏时保存答案
this.saveAnswers()
},
methods: {
async loadExamData() {
this.loading = true
try {
const response = await getExamQuestions(this.examId)
if (response.code === 200 && response.data) {
this.examInfo = response.data.exam || {}
this.questions = response.data.questions || []
this.duration = (this.examInfo.duration || 120) * 60 // 转换为秒
// 初始化倒计时
this.remainingTime = this.duration
this.startTime = Date.now()
this.startTimer()
// 加载本地保存的答案
this.loadAnswers()
} else {
throw new Error(response.msg || '加载失败')
}
} catch (error) {
console.error('加载考试数据失败', error)
const errorMsg = error.message || '加载失败'
// 如果是已做过的提示显示模态框而不是toast
if (errorMsg.includes('已经完成过') || errorMsg.includes('不能重复作答')) {
uni.showModal({
title: '提示',
content: errorMsg,
showCancel: false,
confirmText: '我知道了',
success: () => {
uni.navigateBack()
}
})
} else {
uni.showToast({
title: errorMsg,
icon: 'none',
duration: 2000
})
setTimeout(() => {
uni.navigateBack()
}, 2000)
}
} finally {
this.loading = false
}
},
startTimer() {
this.clearTimer()
this.timer = setInterval(() => {
const elapsed = Math.floor((Date.now() - this.startTime) / 1000)
this.remainingTime = Math.max(0, this.duration - elapsed)
if (this.remainingTime <= 0) {
this.clearTimer()
uni.showModal({
title: '时间到',
content: '考试时间已到,系统将自动提交答案。',
showCancel: false,
success: () => {
this.submitExam()
}
})
}
}, 1000)
},
clearTimer() {
if (this.timer) {
clearInterval(this.timer)
this.timer = null
}
},
formatTime(seconds) {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = seconds % 60
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
return `${minutes}:${secs.toString().padStart(2, '0')}`
},
parseOptions(optionsStr) {
if (!optionsStr) return []
try {
return JSON.parse(optionsStr)
} catch (e) {
// 如果不是JSON尝试按逗号分割
return optionsStr.split(',').map(s => s.trim())
}
},
getOptionLabel(index) {
return String.fromCharCode(65 + index) + '.'
},
selectAnswer(questionId, answer) {
this.$set(this.answers, questionId, answer)
this.saveAnswers()
},
toggleMultipleAnswer(questionId, option) {
const currentAnswer = this.answers[questionId] || ''
const answerArray = currentAnswer ? currentAnswer.split(',') : []
const index = answerArray.indexOf(option)
if (index > -1) {
answerArray.splice(index, 1)
} else {
answerArray.push(option)
}
this.$set(this.answers, questionId, answerArray.join(','))
this.saveAnswers()
},
isOptionSelected(questionId, option) {
const answer = this.answers[questionId] || ''
return answer.split(',').includes(option)
},
prevQuestion() {
if (this.currentIndex > 0) {
this.currentIndex--
}
},
nextQuestion() {
if (this.currentIndex < this.questions.length - 1) {
this.currentIndex++
}
},
jumpToQuestion(index) {
this.currentIndex = index
this.showAnswerSheet = false
},
saveAnswers() {
// 保存答案到本地存储包含用户ID以区分不同用户
const userInfo = uni.getStorageSync('userInfo') || {}
const userId = userInfo.userId || userInfo.id
const key = `exam_answers_${this.examId}`
uni.setStorageSync(key, {
userId: userId, // 保存用户ID
answers: this.answers,
timestamp: Date.now()
})
},
loadAnswers() {
// 从本地存储加载答案,但只加载当前用户的答案
const userInfo = uni.getStorageSync('userInfo') || {}
const currentUserId = userInfo.userId || userInfo.id
const key = `exam_answers_${this.examId}`
const saved = uni.getStorageSync(key)
// 验证用户ID只有当前用户的答案才会加载
if (saved && saved.answers) {
if (saved.userId && saved.userId === currentUserId) {
// 用户ID匹配加载答案
this.answers = { ...this.answers, ...saved.answers }
console.log('加载了当前用户的答案缓存')
} else {
// 用户ID不匹配清除旧答案
console.log('检测到用户切换,清除旧答案缓存')
uni.removeStorageSync(key)
this.answers = {}
}
}
},
confirmExit() {
// 保存答案到本地
this.saveAnswers()
// 标记正在退出,防止 onBackPress 拦截
this.isExiting = true
this.showExitModal = false
this.clearTimer()
// 返回上一页
this.$nextTick(() => {
const pages = getCurrentPages()
console.log('当前页面栈:', pages.length)
// 如果页面栈大于1说明有上一页可以返回
if (pages.length > 1) {
uni.navigateBack({
delta: 1
})
} else {
// 如果没有上一页,跳转到考试列表页
uni.redirectTo({
url: '/pages/exam/list'
})
}
})
},
async submitExam() {
this.showSubmitModal = false
this.clearTimer()
// 检查是否有题目
if (!this.questions || this.questions.length === 0) {
uni.showToast({
title: '没有可提交的题目',
icon: 'none'
})
return
}
// 构建答案数组
const answerList = this.questions.map(question => {
if (!question || !question.id) {
console.warn('题目数据不完整:', question)
return null
}
return {
questionId: question.id,
answer: this.answers[question.id] || ''
}
}).filter(item => item !== null) // 过滤掉无效项
// 检查答案列表是否为空
if (answerList.length === 0) {
uni.showToast({
title: '答案数据异常,请重试',
icon: 'none'
})
return
}
// 计算用时(秒)
const duration = Math.floor((Date.now() - this.startTime) / 1000)
// 调试信息
console.log('提交答案数据:', {
examId: this.examId,
answerCount: answerList.length,
questionCount: this.questions.length,
duration: duration,
answerList: answerList
})
uni.showLoading({
title: '提交中...',
mask: true
})
try {
const response = await submitExamAnswer(this.examId, answerList, duration)
if (response.code === 200) {
// 清除本地保存的答案
const key = `exam_answers_${this.examId}`
uni.removeStorageSync(key)
uni.hideLoading()
uni.showToast({
title: '提交成功',
icon: 'success'
})
// 跳转到结果页面
setTimeout(() => {
// 检查响应数据中是否有 id
const scoreId = (response.data && response.data.id) ? response.data.id : ''
uni.redirectTo({
url: `/pages/exam/result?examId=${this.examId}${scoreId ? `&scoreId=${scoreId}` : ''}`
})
}, 1500)
} else {
throw new Error(response.msg || '提交失败')
}
} catch (error) {
uni.hideLoading()
console.error('提交答案失败', error)
// 解析错误信息
let errorMessage = '网络错误,请重试'
if (error && error.message) {
errorMessage = error.message
// 如果是后端返回的数组越界错误,给出更友好的提示
if (errorMessage.includes('Index:') && errorMessage.includes('Size:')) {
errorMessage = '提交数据格式错误,请重试'
}
// 如果是已提交的错误,显示特殊提示
if (errorMessage.includes('已提交') || errorMessage.includes('重复提交') || errorMessage.includes('已经完成过')) {
uni.showModal({
title: '提示',
content: errorMessage,
showCancel: false,
confirmText: '返回',
success: () => {
uni.navigateBack()
}
})
return
}
} else if (error && error.errMsg) {
errorMessage = error.errMsg
}
uni.showModal({
title: '提交失败',
content: errorMessage,
showCancel: true,
confirmText: '重试',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
this.submitExam()
} else {
// 重新启动计时器
this.startTimer()
}
}
})
}
}
}
}
</script>
<style lang="scss" scoped>
.exam-detail-container {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #f5f7fa;
}
.exam-header {
background: linear-gradient(135deg, rgb(55 140 224) 0%, rgb(45 120 200) 100%);
padding: 30rpx;
padding-top: calc(var(--status-bar-height) + 30rpx);
display: flex;
justify-content: space-between;
align-items: center;
@media (min-width: 768px) {
padding: 30rpx 40rpx;
padding-top: calc(var(--status-bar-height) + 30rpx);
}
.header-info {
flex: 1;
.exam-name {
display: block;
font-size: 32rpx;
font-weight: bold;
color: #fff;
margin-bottom: 10rpx;
}
.exam-subject {
display: block;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.8);
}
}
.timer-section {
text-align: right;
.timer-label {
display: block;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.8);
margin-bottom: 8rpx;
}
.timer-value {
display: block;
font-size: 36rpx;
font-weight: bold;
color: #fff;
&.timer-warning {
color: #ffeb3b;
animation: blink 1s infinite;
}
}
}
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.answer-sheet {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
display: flex;
align-items: flex-end;
.sheet-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx;
background: #fff;
border-bottom: 1rpx solid #f0f0f0;
.sheet-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.close-btn {
font-size: 48rpx;
color: #999;
line-height: 1;
}
}
.sheet-content {
background: #fff;
padding: 30rpx;
max-height: 60vh;
overflow-y: auto;
display: flex;
flex-wrap: wrap;
gap: 20rpx;
.sheet-item {
width: 80rpx;
height: 80rpx;
line-height: 80rpx;
text-align: center;
border: 2rpx solid #ddd;
border-radius: 8rpx;
font-size: 28rpx;
color: #333;
background: #fff;
&.answered {
background: rgb(55 140 224);
color: #fff;
border-color: rgb(55 140 224);
}
&.current {
border-color: rgb(55 140 224);
border-width: 4rpx;
}
}
}
}
.questions-container {
flex: 1;
padding: 20rpx;
overflow-y: auto;
}
.question-item {
background: #fff;
border-radius: 16rpx;
padding: 30rpx;
margin-bottom: 20rpx;
.question-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
padding-bottom: 20rpx;
border-bottom: 1rpx solid #f0f0f0;
.question-number {
font-size: 28rpx;
font-weight: bold;
color: #333;
}
.question-score {
font-size: 24rpx;
color: rgb(55 140 224);
background: #e6f7ff;
padding: 4rpx 12rpx;
border-radius: 8rpx;
}
}
.question-content {
margin-bottom: 30rpx;
.question-text {
font-size: 30rpx;
line-height: 1.6;
color: #333;
.multiple-tip {
color: rgb(55 140 224);
font-size: 26rpx;
font-weight: 500;
margin-left: 8rpx;
}
}
}
.multiple-hint {
padding: 16rpx 20rpx;
margin-bottom: 20rpx;
background: linear-gradient(135deg, #e6f7ff 0%, #f0f9ff 100%);
border-left: 4rpx solid rgb(55 140 224);
border-radius: 8rpx;
font-size: 28rpx;
color: rgb(55 140 224);
line-height: 1.5;
}
}
.options-list {
.option-item {
display: flex;
align-items: center;
padding: 20rpx;
margin-bottom: 16rpx;
border: 2rpx solid #e0e0e0;
border-radius: 12rpx;
background: #fff;
&.selected {
border-color: rgb(55 140 224);
background: #e6f7ff;
}
.option-radio {
width: 40rpx;
height: 40rpx;
border: 2rpx solid #ddd;
border-radius: 50%;
margin-right: 20rpx;
display: flex;
align-items: center;
justify-content: center;
.radio-dot {
width: 24rpx;
height: 24rpx;
background: rgb(55 140 224);
border-radius: 50%;
}
}
.option-checkbox {
width: 40rpx;
height: 40rpx;
border: 2rpx solid #ddd;
border-radius: 8rpx;
margin-right: 20rpx;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
.checkbox-icon {
color: rgb(55 140 224);
font-size: 28rpx;
font-weight: bold;
}
}
.option-label {
font-size: 28rpx;
color: rgb(55 140 224);
font-weight: bold;
margin-right: 12rpx;
min-width: 60rpx;
}
.option-text {
flex: 1;
font-size: 28rpx;
color: #333;
}
}
}
.fill-answer, .essay-answer {
.fill-input, .essay-input {
width: 100%;
min-height: 120rpx;
padding: 20rpx;
border: 2rpx solid #e0e0e0;
border-radius: 12rpx;
font-size: 28rpx;
background: #fff;
}
.essay-input {
min-height: 200rpx;
}
}
.bottom-actions {
display: flex;
gap: 10rpx;
padding: 20rpx;
background: #fff;
border-top: 1rpx solid #f0f0f0;
button {
flex: 1;
padding: 20rpx;
border-radius: 8rpx;
font-size: 28rpx;
border: none;
&.btn-sheet {
background: #f5f5f5;
color: #333;
}
&.btn-prev, &.btn-next {
background: #e6f7ff;
color: rgb(55 140 224);
&:disabled {
background: #f5f5f5;
color: #ccc;
}
}
&.btn-submit {
background: linear-gradient(135deg, rgb(55 140 224) 0%, rgb(45 120 200) 100%);
color: #fff;
font-weight: bold;
}
}
}
.submit-modal,
.exit-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 2000;
.modal-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4rpx);
}
.modal-content {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 600rpx;
max-width: 90%;
background: #fff;
border-radius: 24rpx;
overflow: hidden;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.15);
@media (min-width: 768px) {
width: 500px;
border-radius: 20px;
}
.modal-header {
padding: 40rpx 30rpx 30rpx;
text-align: center;
border-bottom: 1rpx solid #f0f0f0;
@media (min-width: 768px) {
padding: 36px 30px 24px;
}
.modal-title {
font-size: 36rpx;
font-weight: bold;
color: #1a1a1a;
@media (min-width: 768px) {
font-size: 32px;
}
}
}
.modal-body {
padding: 40rpx 30rpx;
@media (min-width: 768px) {
padding: 36px 30px;
}
.modal-text {
display: block;
font-size: 30rpx;
color: #333;
margin-bottom: 24rpx;
text-align: center;
line-height: 1.6;
@media (min-width: 768px) {
font-size: 28px;
margin-bottom: 20px;
}
}
.answer-stats {
background: #f8f9fa;
border-radius: 12rpx;
padding: 24rpx;
display: flex;
justify-content: space-around;
@media (min-width: 768px) {
border-radius: 10px;
padding: 20px;
}
.stats-item {
font-size: 28rpx;
color: #666;
line-height: 1.5;
@media (min-width: 768px) {
font-size: 26px;
}
}
}
}
.modal-footer {
display: flex;
border-top: 1rpx solid #f0f0f0;
button {
flex: 1;
padding: 28rpx;
border: none;
font-size: 30rpx;
font-weight: 500;
background: transparent;
transition: background-color 0.2s;
@media (min-width: 768px) {
padding: 24px;
font-size: 28px;
}
&.btn-cancel {
color: #666;
border-right: 1rpx solid #f0f0f0;
&:active {
background: #f5f5f5;
}
}
&.btn-confirm {
color: rgb(55 140 224);
font-weight: 600;
&:active {
background: rgba(55, 140, 224, 0.1);
}
}
&.btn-confirm-exit {
color: #ff4d4f;
font-weight: 600;
&:active {
background: rgba(255, 77, 79, 0.1);
}
}
}
}
}
}
</style>