Polish quiz navigation and submission flow
This commit is contained in:
parent
6ad79be274
commit
43ddf589a3
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user