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">
import { computed, onBeforeUnmount, onMounted, shallowRef, watch } from 'vue'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Grid } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, ArrowRight, Finished, Grid, House } from '@element-plus/icons-vue'
import { useQuizStore } from '@/stores/quiz'
const route = useRoute()
@ -17,6 +17,7 @@ const autoSubmitting = shallowRef(false)
const loadingAttempt = shallowRef(true)
const touchStartX = shallowRef(0)
const touchStartY = shallowRef(0)
const slideName = shallowRef('slide-next')
let timer: number | undefined
let positionTimer: number | undefined
@ -47,6 +48,8 @@ const displaySelected = computed(() => {
const singleDisplaySelected = computed(() => displaySelected.value[0])
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 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 }) {
if (!showAnswer.value) return ''
@ -85,6 +88,7 @@ async function selectSingle(optionId: number) {
async function go(index: number) {
if (!quiz.attempt || index < 0 || index >= quiz.attempt.items.length) return
slideName.value = index > quiz.currentIndex ? 'slide-next' : 'slide-prev'
quiz.setPosition(index)
syncLocalAnswer()
sheetOpen.value = false
@ -113,6 +117,17 @@ function handleTouchEnd(event: TouchEvent) {
async function submitPaper(auto = false) {
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.submit()
ElMessage.success(auto ? '时间到,已自动完成' : '练习已完成')
@ -170,6 +185,8 @@ watch(remainingSeconds, async (value) => {
<template>
<main class="quiz-page" v-if="!loadingAttempt && quiz.attempt && item">
<section class="question-panel" @touchstart.passive="handleTouchStart" @touchend.passive="handleTouchEnd">
<Transition :name="slideName" mode="out-in">
<article :key="item.id" class="question-slide">
<div class="question-meta">
<div class="meta-left">
<ElTag>{{ typeLabel }}</ElTag>
@ -221,19 +238,40 @@ watch(remainingSeconds, async (value) => {
<p v-if="item.question.type === 'blank'">正确答案{{ item.question.answers?.join('') || '暂无标准答案' }}</p>
<p>{{ item.question.explanation || '暂无解析' }}</p>
</section>
</article>
</Transition>
</section>
<footer class="quiz-footer">
<ElButton :icon="Grid" circle @click="sheetOpen = true" />
<ElButton plain @click="router.push('/quiz')">返回</ElButton>
<ElButton :disabled="quiz.currentIndex === 0" @click="go(quiz.currentIndex - 1)">上一题</ElButton>
<ElButton v-if="showConfirmAnswer" type="primary" @click="submitAnswer">
<div class="footer-inner">
<div class="footer-group footer-group--left">
<ElButton class="footer-icon footer-home" :icon="House" circle @click="router.push('/quiz')" />
<ElButton class="footer-nav footer-prev" :icon="ArrowLeft" :disabled="isFirstQuestion" @click="go(quiz.currentIndex - 1)">
上一题
</ElButton>
</div>
<div class="footer-group footer-group--center">
<ElButton class="footer-icon footer-sheet" :icon="Grid" circle @click="sheetOpen = true" />
</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 :disabled="quiz.currentIndex >= quiz.attempt.items.length - 1" @click="go(quiz.currentIndex + 1)">下一题</ElButton>
<ElButton plain :disabled="quiz.attempt.status !== 'in_progress'" @click="submitPaper(false)">
{{ quiz.attempt.status === 'submitted' ? '已完成' : '完成练习' }}
<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>
<ElDrawer v-model="sheetOpen" title="答题卡" direction="btt" size="48%">
@ -274,6 +312,11 @@ watch(remainingSeconds, async (value) => {
margin: 0 auto;
padding: 24px 16px 120px;
touch-action: pan-y;
overflow: hidden;
}
.question-slide {
min-height: 360px;
}
.question-meta {
@ -393,15 +436,98 @@ watch(remainingSeconds, async (value) => {
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: center;
gap: 10px;
padding: 12px;
padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
border-top: 1px solid var(--qq-line);
background: rgba(247, 248, 244, 0.9);
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 {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(42px, 1fr));
@ -436,7 +562,7 @@ watch(remainingSeconds, async (value) => {
@media (max-width: 640px) {
.question-panel {
padding: 18px 12px 118px;
padding: 18px 12px 132px;
}
.question-panel h1 {
@ -444,8 +570,79 @@ watch(remainingSeconds, async (value) => {
}
.quiz-footer {
justify-content: flex-start;
overflow-x: auto;
padding: 8px 10px calc(8px + env(safe-area-inset-bottom));
}
.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>