Polish quiz navigation and submission flow

This commit is contained in:
Boen_Shi 2026-06-25 21:05:39 +08:00
parent 6ad79be274
commit 43ddf589a3

View File

@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, shallowRef, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, shallowRef, watch } from 'vue'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router' import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Grid } from '@element-plus/icons-vue' import { ArrowLeft, ArrowRight, Finished, Grid, House } from '@element-plus/icons-vue'
import { useQuizStore } from '@/stores/quiz' import { useQuizStore } from '@/stores/quiz'
const route = useRoute() const route = useRoute()
@ -17,6 +17,7 @@ const autoSubmitting = shallowRef(false)
const loadingAttempt = shallowRef(true) const loadingAttempt = shallowRef(true)
const touchStartX = shallowRef(0) const touchStartX = shallowRef(0)
const touchStartY = shallowRef(0) const touchStartY = shallowRef(0)
const slideName = shallowRef('slide-next')
let timer: number | undefined let timer: number | undefined
let positionTimer: number | undefined let positionTimer: number | undefined
@ -47,6 +48,8 @@ const displaySelected = computed(() => {
const singleDisplaySelected = computed(() => displaySelected.value[0]) const singleDisplaySelected = computed(() => displaySelected.value[0])
const showConfirmAnswer = computed(() => quiz.attempt?.status === 'in_progress' && (isMultiple.value || isBlank.value) && !showAnswer.value) const showConfirmAnswer = computed(() => quiz.attempt?.status === 'in_progress' && (isMultiple.value || isBlank.value) && !showAnswer.value)
const optionsLocked = computed(() => quiz.attempt?.status !== 'in_progress' || showAnswer.value) const optionsLocked = computed(() => quiz.attempt?.status !== 'in_progress' || showAnswer.value)
const isFirstQuestion = computed(() => quiz.currentIndex === 0)
const isLastQuestion = computed(() => !quiz.attempt || quiz.currentIndex >= quiz.attempt.items.length - 1)
function optionClass(option: { id: number; is_correct: boolean }) { function optionClass(option: { id: number; is_correct: boolean }) {
if (!showAnswer.value) return '' if (!showAnswer.value) return ''
@ -85,6 +88,7 @@ async function selectSingle(optionId: number) {
async function go(index: number) { async function go(index: number) {
if (!quiz.attempt || index < 0 || index >= quiz.attempt.items.length) return if (!quiz.attempt || index < 0 || index >= quiz.attempt.items.length) return
slideName.value = index > quiz.currentIndex ? 'slide-next' : 'slide-prev'
quiz.setPosition(index) quiz.setPosition(index)
syncLocalAnswer() syncLocalAnswer()
sheetOpen.value = false sheetOpen.value = false
@ -113,6 +117,17 @@ function handleTouchEnd(event: TouchEvent) {
async function submitPaper(auto = false) { async function submitPaper(auto = false) {
if (!quiz.attempt || quiz.attempt.status !== 'in_progress') return if (!quiz.attempt || quiz.attempt.status !== 'in_progress') return
if (!auto) {
try {
await ElMessageBox.confirm('提交后将完成本次练习,确认提交吗?', '提交确认', {
type: 'warning',
confirmButtonText: '确认提交',
cancelButtonText: '取消',
})
} catch {
return
}
}
await quiz.savePosition(quiz.currentIndex, true).catch(() => undefined) await quiz.savePosition(quiz.currentIndex, true).catch(() => undefined)
await quiz.submit() await quiz.submit()
ElMessage.success(auto ? '时间到,已自动完成' : '练习已完成') ElMessage.success(auto ? '时间到,已自动完成' : '练习已完成')
@ -170,70 +185,93 @@ watch(remainingSeconds, async (value) => {
<template> <template>
<main class="quiz-page" v-if="!loadingAttempt && quiz.attempt && item"> <main class="quiz-page" v-if="!loadingAttempt && quiz.attempt && item">
<section class="question-panel" @touchstart.passive="handleTouchStart" @touchend.passive="handleTouchEnd"> <section class="question-panel" @touchstart.passive="handleTouchStart" @touchend.passive="handleTouchEnd">
<div class="question-meta"> <Transition :name="slideName" mode="out-in">
<div class="meta-left"> <article :key="item.id" class="question-slide">
<ElTag>{{ typeLabel }}</ElTag> <div class="question-meta">
<span>{{ quiz.currentIndex + 1 }} / {{ quiz.attempt.total_questions }}</span> <div class="meta-left">
</div> <ElTag>{{ typeLabel }}</ElTag>
<div class="timer" :class="{ 'timer--danger': remainingSeconds !== null && remainingSeconds <= 60 }"> <span>{{ quiz.currentIndex + 1 }} / {{ quiz.attempt.total_questions }}</span>
{{ remainingLabel }} </div>
</div> <div class="timer" :class="{ 'timer--danger': remainingSeconds !== null && remainingSeconds <= 60 }">
</div> {{ remainingLabel }}
<h1>{{ item.question.content }}</h1> </div>
</div>
<h1>{{ item.question.content }}</h1>
<ElCheckboxGroup v-if="isMultiple" :model-value="displaySelected" class="option-list" @change="updateSelected"> <ElCheckboxGroup v-if="isMultiple" :model-value="displaySelected" class="option-list" @change="updateSelected">
<ElCheckbox <ElCheckbox
v-for="option in item.question.options" v-for="option in item.question.options"
:key="option.id" :key="option.id"
:value="option.id" :value="option.id"
:class="optionClass(option)" :class="optionClass(option)"
:disabled="optionsLocked" :disabled="optionsLocked"
border border
> >
{{ option.content }} {{ option.content }}
</ElCheckbox> </ElCheckbox>
</ElCheckboxGroup> </ElCheckboxGroup>
<ElInput <ElInput
v-else-if="isBlank" v-else-if="isBlank"
v-model="blankAnswer" v-model="blankAnswer"
class="blank-input" class="blank-input"
type="textarea" type="textarea"
:rows="4" :rows="4"
:disabled="optionsLocked" :disabled="optionsLocked"
placeholder="请输入答案" placeholder="请输入答案"
/> />
<ElRadioGroup v-else :model-value="singleDisplaySelected" class="option-list"> <ElRadioGroup v-else :model-value="singleDisplaySelected" class="option-list">
<ElRadio <ElRadio
v-for="option in item.question.options" v-for="option in item.question.options"
:key="option.id" :key="option.id"
:value="option.id" :value="option.id"
:class="optionClass(option)" :class="optionClass(option)"
:disabled="optionsLocked" :disabled="optionsLocked"
border border
@click="selectSingle(option.id)" @click="selectSingle(option.id)"
> >
{{ option.content }} {{ option.content }}
</ElRadio> </ElRadio>
</ElRadioGroup> </ElRadioGroup>
<section v-if="showAnswer" class="answer-panel"> <section v-if="showAnswer" class="answer-panel">
<strong>答案与解析</strong> <strong>答案与解析</strong>
<p v-if="item.question.type === 'blank'">正确答案{{ item.question.answers?.join('') || '暂无标准答案' }}</p> <p v-if="item.question.type === 'blank'">正确答案{{ item.question.answers?.join('') || '暂无标准答案' }}</p>
<p>{{ item.question.explanation || '暂无解析' }}</p> <p>{{ item.question.explanation || '暂无解析' }}</p>
</section> </section>
</article>
</Transition>
</section> </section>
<footer class="quiz-footer"> <footer class="quiz-footer">
<ElButton :icon="Grid" circle @click="sheetOpen = true" /> <div class="footer-inner">
<ElButton plain @click="router.push('/quiz')">返回</ElButton> <div class="footer-group footer-group--left">
<ElButton :disabled="quiz.currentIndex === 0" @click="go(quiz.currentIndex - 1)">上一题</ElButton> <ElButton class="footer-icon footer-home" :icon="House" circle @click="router.push('/quiz')" />
<ElButton v-if="showConfirmAnswer" type="primary" @click="submitAnswer"> <ElButton class="footer-nav footer-prev" :icon="ArrowLeft" :disabled="isFirstQuestion" @click="go(quiz.currentIndex - 1)">
确认本题 上一题
</ElButton> </ElButton>
<ElButton :disabled="quiz.currentIndex >= quiz.attempt.items.length - 1" @click="go(quiz.currentIndex + 1)">下一题</ElButton> </div>
<ElButton plain :disabled="quiz.attempt.status !== 'in_progress'" @click="submitPaper(false)"> <div class="footer-group footer-group--center">
{{ quiz.attempt.status === 'submitted' ? '已完成' : '完成练习' }} <ElButton class="footer-icon footer-sheet" :icon="Grid" circle @click="sheetOpen = true" />
</ElButton> </div>
<div class="footer-group footer-group--right">
<ElButton class="footer-nav footer-next" type="primary" plain :disabled="isLastQuestion" @click="go(quiz.currentIndex + 1)">
下一题
<ElIcon class="footer-nav-icon"><ArrowRight /></ElIcon>
</ElButton>
<ElButton v-if="showConfirmAnswer" class="footer-primary" type="primary" @click="submitAnswer">
确认本题
</ElButton>
<ElButton
class="footer-finish"
:class="{ 'footer-finish--defer': showConfirmAnswer }"
:icon="Finished"
:disabled="quiz.attempt.status !== 'in_progress'"
@click="submitPaper(false)"
>
{{ quiz.attempt.status === 'submitted' ? '已提交' : '提交' }}
</ElButton>
</div>
</div>
</footer> </footer>
<ElDrawer v-model="sheetOpen" title="答题卡" direction="btt" size="48%"> <ElDrawer v-model="sheetOpen" title="答题卡" direction="btt" size="48%">
@ -274,6 +312,11 @@ watch(remainingSeconds, async (value) => {
margin: 0 auto; margin: 0 auto;
padding: 24px 16px 120px; padding: 24px 16px 120px;
touch-action: pan-y; touch-action: pan-y;
overflow: hidden;
}
.question-slide {
min-height: 360px;
} }
.question-meta { .question-meta {
@ -393,15 +436,98 @@ watch(remainingSeconds, async (value) => {
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
display: flex; padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
justify-content: center;
gap: 10px;
padding: 12px;
border-top: 1px solid var(--qq-line); border-top: 1px solid var(--qq-line);
background: rgba(247, 248, 244, 0.9); background: rgba(247, 248, 244, 0.9);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
} }
.footer-inner {
width: fit-content;
max-width: 100%;
min-height: 52px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 8px;
border: 1px solid rgba(223, 229, 221, 0.9);
border-radius: 16px;
background: rgba(255, 255, 255, 0.48);
box-shadow: 0 10px 28px rgba(23, 33, 27, 0.08);
}
.footer-inner :deep(.el-button),
.footer-group :deep(.el-button) {
margin-left: 0;
}
.footer-group {
display: flex;
align-items: center;
gap: 10px;
}
.footer-icon {
width: 44px;
height: 44px;
background: rgba(255, 255, 255, 0.62);
}
.footer-nav,
.footer-primary,
.footer-finish {
height: 44px;
border-radius: 8px;
font-weight: 700;
}
.footer-nav {
width: 138px;
}
.footer-primary {
width: 132px;
}
.footer-finish {
width: 96px;
}
.footer-primary {
box-shadow: 0 8px 18px rgba(31, 111, 91, 0.18);
}
.footer-finish {
background: rgba(255, 255, 255, 0.62);
}
.footer-nav-icon {
margin-left: 4px;
}
.slide-next-enter-active,
.slide-next-leave-active,
.slide-prev-enter-active,
.slide-prev-leave-active {
transition:
transform 220ms ease,
opacity 180ms ease;
}
.slide-next-enter-from,
.slide-prev-leave-to {
opacity: 0;
transform: translateX(42px);
}
.slide-next-leave-to,
.slide-prev-enter-from {
opacity: 0;
transform: translateX(-42px);
}
.answer-sheet { .answer-sheet {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(42px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(42px, 1fr));
@ -436,7 +562,7 @@ watch(remainingSeconds, async (value) => {
@media (max-width: 640px) { @media (max-width: 640px) {
.question-panel { .question-panel {
padding: 18px 12px 118px; padding: 18px 12px 132px;
} }
.question-panel h1 { .question-panel h1 {
@ -444,8 +570,79 @@ watch(remainingSeconds, async (value) => {
} }
.quiz-footer { .quiz-footer {
justify-content: flex-start; padding: 8px 10px calc(8px + env(safe-area-inset-bottom));
overflow-x: auto; }
.footer-inner {
width: 100%;
display: grid;
grid-template-columns: 42px minmax(0, 1fr) minmax(0, 1fr) 42px;
grid-template-areas:
"home prev next sheet"
"finish finish finish finish";
gap: 8px;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.footer-group {
display: contents;
}
.footer-icon {
width: 42px;
height: 42px;
}
.footer-home {
grid-area: home;
}
.footer-sheet {
grid-area: sheet;
}
.footer-nav {
width: 100%;
height: 42px;
}
.footer-prev {
grid-area: prev;
}
.footer-next {
grid-area: next;
}
.footer-primary {
grid-area: finish;
width: 100%;
height: 42px;
min-width: 0;
padding-inline: 10px;
}
.footer-finish {
grid-area: finish;
width: 100%;
height: 40px;
}
.footer-finish--defer {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.slide-next-enter-active,
.slide-next-leave-active,
.slide-prev-enter-active,
.slide-prev-leave-active {
transition: none;
} }
} }
</style> </style>