elearning/Frontend-Learner/pages/classroom/quiz.vue

452 lines
19 KiB
Vue

<script setup lang="ts">
/**
* @file quiz.vue
* @description Quiz Interface.
* Manages the entire quiz lifecycle: Start -> Taking -> Results -> Review.
* Features a timer, question navigation, and detailed result analysis.
*/
definePageMeta({
layout: false,
middleware: 'auth'
})
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const $q = useQuasar()
const { fetchCourseLearningInfo, fetchLessonContent, submitQuiz: apiSubmitQuiz, markLessonComplete } = useCourse()
// State Management
const currentScreen = ref<'start' | 'taking' | 'result' | 'review'>('start')
const timeLeft = ref(0)
let timerInterval: ReturnType<typeof setInterval> | null = null
const courseId = Number(route.query.course_id)
const lessonId = Number(route.query.lesson_id)
const courseData = ref<any>(null)
const quizData = ref<any>(null)
const isLoading = ref(true)
const isSubmitting = ref(false)
// Quiz Taking State
const currentQuestionIndex = ref(0)
const userAnswers = ref<Record<number, number>>({}) // questionId -> choiceId
const quizResult = ref<any>(null)
// Computed
const currentQuestion = computed(() => {
if (!quizData.value || !quizData.value.questions) return null
return quizData.value.questions[currentQuestionIndex.value]
})
const totalQuestions = computed(() => {
return quizData.value?.questions?.length || 0
})
const timerDisplay = computed(() => {
const minutes = Math.floor(timeLeft.value / 60)
const seconds = timeLeft.value % 60
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
})
// Helper for localization
const getLocalizedText = (text: any) => {
if (!text) return ''
if (typeof text === 'string') return text
return text.th || text.en || ''
}
// Data Fetching
const loadData = async () => {
isLoading.value = true
try {
if (courseId) {
const courseRes = await fetchCourseLearningInfo(courseId)
if (courseRes.success) courseData.value = courseRes.data
}
if (courseId && lessonId) {
const lessonRes = await fetchLessonContent(courseId, lessonId)
if (lessonRes.success) {
// Determine if data is directly the quiz or nested
quizData.value = lessonRes.data.quiz || lessonRes.data
if (quizData.value?.time_limit) {
timeLeft.value = quizData.value.time_limit * 60
}
}
}
} catch (error) {
console.error('Error loading quiz data:', error)
} finally {
isLoading.value = false
}
}
// Helper for shuffling
const shuffleArray = <T>(array: T[]): T[] => {
return array
.map(value => ({ value, sort: Math.random() }))
.sort((a, b) => a.sort - b.sort)
.map(({ value }) => value)
}
// Quiz Actions
const startQuiz = () => {
// Deep copy to reset and apply shuffle
const rawQuiz = JSON.parse(JSON.stringify(quizData.value))
if (rawQuiz) {
// Shuffle Questions
if (rawQuiz.shuffle_questions && rawQuiz.questions) {
rawQuiz.questions = shuffleArray(rawQuiz.questions)
}
// Shuffle Choices
if (rawQuiz.shuffle_choices && rawQuiz.questions) {
rawQuiz.questions.forEach((q: any) => {
if (q.choices) {
q.choices = shuffleArray(q.choices)
}
})
}
// Update state with shuffled data
quizData.value = rawQuiz
}
currentScreen.value = 'taking'
currentQuestionIndex.value = 0
userAnswers.value = {}
if (quizData.value?.time_limit) {
timeLeft.value = quizData.value.time_limit * 60
timerInterval = setInterval(() => {
if (timeLeft.value > 0) timeLeft.value--
else submitQuiz(true)
}, 1000)
}
}
const selectAnswer = (choiceId: number) => {
if (currentQuestion.value) {
userAnswers.value[currentQuestion.value.id] = choiceId
}
}
const nextQuestion = () => {
if (!currentQuestion.value) return
// Check if answered
if (!userAnswers.value[currentQuestion.value.id]) {
// Show warning
$q.notify({
type: 'warning',
message: t('quiz.pleaseSelectAnswer', 'กรุณาเลือกคำตอบ'),
position: 'top',
timeout: 2000
})
return
}
if (currentQuestionIndex.value < totalQuestions.value - 1) {
currentQuestionIndex.value++
}
}
const prevQuestion = () => {
if (currentQuestionIndex.value > 0) {
currentQuestionIndex.value--
}
}
const retryQuiz = () => {
currentScreen.value = 'start'
quizResult.value = null
}
const submitQuiz = async (auto = false) => {
if (!auto && !confirm(t('quiz.submitConfirm'))) return
if (timerInterval) clearInterval(timerInterval)
isSubmitting.value = true
currentScreen.value = 'result' // Switch to result screen immediately to show loader
try {
// Prepare Payload
const answersPayload = Object.entries(userAnswers.value).map(([qId, cId]) => ({
question_id: Number(qId),
choice_id: cId
}))
// Call API
const res = await apiSubmitQuiz(courseId, lessonId, answersPayload)
if (res.success) {
quizResult.value = res.data
// Force mark lesson complete if passed (Fix for checkmark issue)
if (res.data.is_passed) {
markLessonComplete(courseId, lessonId).then(() => {
console.log('Explicitly marked lesson complete')
})
}
} else {
// Fallback error handling
alert(res.error || 'Failed to submit quiz')
// Maybe go back to taking?
}
} catch (err) {
console.error('Submit quiz error:', err)
alert('An unexpected error occurred.')
} finally {
isSubmitting.value = false
}
}
const confirmExit = () => {
const target = courseId ? `/classroom/learning?course_id=${courseId}` : '/dashboard/my-courses'
if (currentScreen.value === 'taking') {
if (confirm(t('quiz.exitConfirm'))) {
router.push(target)
}
} else {
router.push(target)
}
}
onMounted(() => {
loadData()
})
onUnmounted(() => {
if (timerInterval) clearInterval(timerInterval)
})
</script>
<template>
<div class="quiz-shell min-h-screen bg-slate-50 dark:bg-[#0b0f1a] text-slate-900 dark:text-slate-200 antialiased selection:bg-blue-500/20 transition-colors">
<!-- Header -->
<header class="h-14 bg-white dark:!bg-[var(--bg-surface)] fixed top-0 inset-x-0 z-[100] flex items-center px-6 border-b border-slate-200 dark:border-white/5 transition-colors">
<div class="flex items-center w-full justify-between">
<div class="flex items-center">
<button
class="inline-flex items-center gap-2 text-slate-900 dark:text-white hover:text-blue-600 dark:hover:text-blue-300 transition-all font-black text-sm md:text-base group mr-4"
@click="confirmExit"
>
<q-icon name="arrow_back" size="24px" class="transition-transform group-hover:-translate-x-1" />
<span>{{ $t('quiz.exitTitle') }}</span>
</button>
<div class="w-[1px] h-4 bg-slate-300 dark:bg-white/10 mx-4"/>
<h1 class="text-base font-bold text-slate-900 dark:text-white truncate max-w-[200px] md:max-w-md hidden md:block">
{{ quizData ? getLocalizedText(quizData.title) : (courseData ? getLocalizedText(courseData.course.title) : $t('quiz.startTitle')) }}
</h1>
</div>
<div v-if="currentScreen === 'taking'" class="flex items-center gap-3">
<div class="hidden md:block text-[10px] font-black uppercase tracking-widest text-slate-400">{{ $t('quiz.timeLeft') }}</div>
<div class="bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400 px-3 py-1 rounded-full font-mono font-bold text-sm border border-blue-100 dark:border-blue-500/20">
{{ timerDisplay }}
</div>
</div>
</div>
</header>
<!-- Main Content Area -->
<main class="pt-14 h-screen flex items-center justify-center overflow-y-auto px-4 custom-scrollbar">
<div v-if="isLoading" class="flex flex-col items-center gap-4">
<q-spinner color="primary" size="3rem" />
<p class="text-sm font-medium text-slate-500">{{ $t('classroom.loadingTitle') }}</p>
</div>
<template v-else>
<!-- 1. START SCREEN -->
<div v-if="currentScreen === 'start'" class="w-full max-w-[640px] animate-fade-in py-12">
<div class="bg-white dark:!bg-[#1e293b] border border-slate-200 dark:border-white/5 rounded-[32px] p-8 md:p-14 shadow-lg dark:shadow-2xl relative overflow-hidden transition-colors">
<div class="flex justify-center mb-10">
<div class="w-20 h-20 rounded-3xl bg-blue-50 dark:bg-blue-500/10 border border-blue-100 dark:border-blue-500/20 flex items-center justify-center shadow-inner">
<q-icon name="quiz" size="2rem" color="primary" />
</div>
</div>
<div class="text-center mb-10">
<h2 class="text-2xl font-black text-slate-900 dark:text-white mb-2 tracking-tight">
{{ quizData ? getLocalizedText(quizData.title) : $t('quiz.startTitle') }}
</h2>
<div class="flex justify-center gap-4 text-sm text-slate-500 dark:text-slate-400 mt-2">
<span v-if="quizData?.questions?.length"><q-icon name="format_list_numbered" /> {{ quizData.questions.length }} {{ $t('quiz.questions') }}</span>
<span v-if="quizData?.time_limit"><q-icon name="schedule" /> {{ quizData.time_limit }} {{ $t('quiz.minutes') }}</span>
</div>
<p class="text-[13px] font-bold text-slate-500 dark:text-slate-400 uppercase tracking-widest leading-none mt-6">
{{ $t('quiz.preparationTitle') }}
</p>
</div>
<!-- Instruction Box -->
<div class="bg-slate-50 dark:bg-[#0b121f]/80 p-8 rounded-3xl mb-8 border border-slate-100 dark:border-white/5">
<h3 class="text-[12px] font-black text-slate-500 dark:text-slate-400 mb-6 uppercase tracking-[0.2em] flex items-center gap-2">
{{ $t('quiz.instructionTitle') }}
</h3>
<ul class="space-y-4">
<li class="flex items-start gap-3">
<span class="w-1.5 h-1.5 rounded-full bg-blue-500 mt-1.5 flex-shrink-0"/>
<span class="text-sm text-slate-600 dark:text-slate-300 font-medium leading-relaxed">
{{ quizData?.description ? getLocalizedText(quizData.description) : $t('quiz.instruction1') }}
</span>
</li>
</ul>
</div>
<button
class="w-full py-5 bg-blue-600 hover:bg-blue-500 text-white rounded-[20px] font-black text-sm tracking-wider transition-all shadow-xl shadow-blue-600/20 active:scale-[0.98]"
@click="startQuiz"
>
{{ $t('quiz.startBtn') }}
</button>
</div>
</div>
<!-- 2. TAKING SCREEN -->
<div v-if="currentScreen === 'taking'" class="w-full max-w-[840px] animate-fade-in py-12">
<div v-if="currentQuestion" class="bg-white dark:!bg-[#1e293b] border border-slate-200 dark:border-white/5 rounded-[32px] p-8 md:p-12 shadow-xl relative overflow-hidden">
<!-- Progress Bar -->
<div class="absolute top-0 left-0 right-0 h-1.5 bg-slate-100 dark:bg-white/5">
<div class="h-full bg-blue-500 transition-all duration-300" :style="{ width: ((currentQuestionIndex + 1) / totalQuestions) * 100 + '%' }"></div>
</div>
<div class="flex justify-between items-start mb-8 mt-2">
<span class="text-xs font-black text-slate-400 uppercase tracking-widest">Question {{ currentQuestionIndex + 1 }} / {{ totalQuestions }}</span>
</div>
<!-- Question Title -->
<h3 class="text-xl md:text-2xl font-bold text-slate-900 dark:text-white mb-10 leading-relaxed">
{{ getLocalizedText(currentQuestion.question) }}
</h3>
<!-- Choices -->
<div class="flex flex-col gap-4 mb-12">
<button
v-for="choice in currentQuestion.choices"
:key="choice.id"
@click="selectAnswer(choice.id)"
class="group relative w-full p-5 rounded-2xl border-2 text-left transition-all duration-200 flex items-center gap-4"
:class="userAnswers[currentQuestion.id] === choice.id
? 'border-blue-500 bg-blue-50 dark:bg-blue-500/10'
: 'border-slate-100 dark:border-white/10 hover:border-blue-200 dark:hover:border-blue-500/30 bg-transparent'"
>
<div class="w-6 h-6 rounded-full border-2 flex items-center justify-center flex-shrink-0 transition-colors"
:class="userAnswers[currentQuestion.id] === choice.id ? 'border-blue-500 bg-blue-500' : 'border-slate-300 dark:border-white/20 group-hover:border-blue-400'"
>
<q-icon v-if="userAnswers[currentQuestion.id] === choice.id" name="check" size="xs" class="text-white" />
</div>
<span class="text-slate-700 dark:text-slate-200 font-medium text-lg">{{ getLocalizedText(choice.text) }}</span>
</button>
</div>
<!-- Controls -->
<div class="flex justify-between items-center pt-8 border-t border-slate-100 dark:border-white/5">
<button
@click="prevQuestion"
:disabled="currentQuestionIndex === 0"
class="px-6 py-3 rounded-xl font-bold text-slate-600 dark:text-slate-300 bg-slate-100 dark:bg-white/5 hover:bg-slate-200 dark:hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed transition-all flex items-center gap-2"
>
<q-icon name="arrow_back" /> {{ $t('common.back', 'ย้อนกลับ') }}
</button>
<button
v-if="currentQuestionIndex < totalQuestions - 1"
@click="nextQuestion"
class="px-8 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-bold transition-all shadow-lg shadow-blue-500/20 flex items-center gap-2"
>
{{ $t('common.next', 'ถัดไป') }} <q-icon name="arrow_forward" />
</button>
<button
v-else
@click="submitQuiz(false)"
class="px-8 py-3 bg-blue-600 text-white rounded-xl font-bold hover:bg-blue-500 transition-all shadow-lg shadow-blue-500/30 flex items-center gap-2"
>
{{ $t('quiz.submitValues') }} <q-icon name="check" />
</button>
</div>
</div>
</div>
<!-- 3. RESULT SCREEN -->
<div v-if="currentScreen === 'result'" class="w-full max-w-[640px] animate-fade-in py-12">
<div class="bg-white dark:!bg-[#1e293b] border border-slate-200 dark:border-white/5 rounded-[40px] p-10 shadow-2xl text-center relative overflow-hidden">
<div v-if="isSubmitting" class="absolute inset-0 bg-white/80 dark:bg-slate-900/80 z-20 flex flex-col items-center justify-center">
<q-spinner color="primary" size="3em" />
<p class="mt-4 font-bold animate-pulse">{{ $t('quiz.submitting') }}</p>
</div>
<div class="mb-8 relative z-10">
<div class="w-28 h-28 rounded-full flex items-center justify-center mx-auto mb-6 shadow-xl border-4"
:class="quizResult?.is_passed ? 'bg-emerald-50 dark:bg-emerald-500/10 border-emerald-100 dark:border-emerald-500/20' : 'bg-red-50 dark:bg-red-500/10 border-red-100 dark:border-red-500/20'"
>
<q-icon :name="quizResult?.is_passed ? 'emoji_events' : 'close'" size="4rem" :color="quizResult?.is_passed ? 'positive' : 'negative'" />
</div>
<h2 class="text-3xl font-black mb-2 text-slate-900 dark:text-white uppercase tracking-tight">
{{ quizResult?.is_passed ? $t('quiz.resultPassed') : $t('quiz.resultFailed') }}
</h2>
<p class="text-slate-500 dark:text-slate-400 font-medium text-lg">
{{ quizResult?.is_passed ? $t('quiz.passMessage') : $t('quiz.failMessage') }}
</p>
</div>
<!-- Score Card -->
<div class="bg-slate-50 dark:bg-[#0b121f] rounded-3xl p-6 mb-8 flex items-center justify-around border border-slate-100 dark:border-white/5">
<div class="text-center">
<div class="text-xs font-black text-slate-400 uppercase tracking-widest mb-1">{{ $t('quiz.scoreLabel') }}</div>
<div class="text-4xl font-black text-blue-600 dark:text-blue-400">{{ quizResult?.score }}<span class="text-lg text-slate-400 font-bold">/{{ quizResult?.total_score }}</span></div>
</div>
<div class="w-[1px] h-12 bg-slate-200 dark:bg-white/10"></div>
<div class="text-center">
<div class="text-xs font-black text-slate-400 uppercase tracking-widest mb-1">{{ $t('quiz.correctLabel') }}</div>
<div class="text-4xl font-black text-emerald-500">{{ quizResult?.correct_answers }}<span class="text-lg text-slate-400 font-bold">/{{ quizResult?.total_questions }}</span></div>
</div>
</div>
<div class="space-y-4 relative z-10">
<button
@click="confirmExit"
class="w-full py-4 bg-blue-600 hover:bg-blue-500 text-white rounded-[20px] font-black text-sm shadow-xl shadow-blue-600/20 hover:scale-[1.02] active:scale-[0.98] transition-all"
>
{{ $t('quiz.backToLesson') }}
</button>
<button
v-if="!quizResult?.is_passed"
@click="retryQuiz"
class="w-full py-4 text-slate-500 hover:text-slate-800 dark:hover:text-white font-bold text-sm transition-colors"
>
{{ $t('quiz.retryBtn') }}
</button>
</div>
</div>
</div>
</template>
</main>
</div>
</template>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.05);
border-radius: 10px;
}
.animate-fade-in {
animation: fadeIn 0.4s ease-out forwards;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
</style>