68. Finishing Touches
npm create vue@latest
이 강의에서는 퀴즈 애플리케이션의 마무리 단계로, 퀴즈 결과를 계산하고 사용자가 퀴즈를 완료한 후 결과 화면으로 전환하는 과정을 다룹니다.
사용자가 선택한 정답의 정확성을 확인하고, 사용자의 점수를 추적하여 적절한 결과를 표시하는 방법을 배웁니다.
또한, 퀴즈를 재시작할 수 있는 리셋 버튼의 기능을 추가합니다.
이 과정에서는 앞서 배운 컴포넌트 분리, 데이터 전달, 애니메이션 적용 등의 기술을 종합적으로 활용하여 사용자에게 매끄러운 인터랙션 경험을 제공하는 것이 목표입니다.
src/main.ts
import './assets/main.css'
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
src/App.vue
<script setup lang="ts">
import {ref} from "vue";
import Questions from "@/components/Questions.vue";
import Result from "@/components/Result.vue";
const questionsAnswered = ref(0);
const totalCorrect = ref(0);
const questions = ref([
{
q: 'What is 2 + 2?',
answers: [
{
text: '4',
is_correct: true
},
{
text: '3',
is_correct: false
},
{
text: 'Fish',
is_correct: false
},
{
text: '5',
is_correct: false
}
]
},
{
q: 'How many letters are in the word "Banana"?',
answers: [
{
text: '5',
is_correct: false
},
{
text: '7',
is_correct: false
},
{
text: '6',
is_correct: true
},
{
text: '12',
is_correct: false
}
]
},
{
q: 'Find the missing letter: C_ke',
answers: [
{
text: 'e',
is_correct: false
},
{
text: 'a',
is_correct: true
},
{
text: 'i',
is_correct: false
}
]
},
])
const results = ref([
{
min: 0,
max: 2,
title: "Try again!",
desc: "Do a little more studying and you may succeed!"
},
{
min: 3,
max: 3,
title: "Wow, you're a genius!",
desc: "Studying has definitely paid off for you!"
}
])
const questionAnswered = (is_correct: boolean) => {
if (is_correct) {
totalCorrect.value++;
}
questionsAnswered.value++;
}
const reset = () => {
questionsAnswered.value = 0;
totalCorrect.value = 0;
}
</script>
<template>
<div class="ctr">
<Transition name="fade" mode="out-in">
<Questions
v-if="questionsAnswered < questions.length"
:questions="questions"
:questionsAnswered="questionsAnswered"
@question-answered="questionAnswered"
/>
<Result v-else
:results="results"
:totalCorrect="totalCorrect"
/>
</Transition>
<button
v-if="questionsAnswered === questions.length"
type="button"
class="reset-btn"
@click.prevent="reset"
>Reset</button>
</div>
</template>
src/components/Questions.vue
<script setup lang="ts">
const props = withDefaults(
defineProps<{
questions: Array<{
q: string
answers: Array<{
text: string
is_correct: boolean
}>
}>
questionsAnswered: number
}>(),
{}
)
const emits = defineEmits(['question-answered'])
const selectAnswer = (is_correct: boolean) => {
emits('question-answered', is_correct)
}
</script>
<template>
<div class="questions-ctr">
<div class="progress">
<div class="bar" :style="{width: `${questionsAnswered / questions.length * 100}%`}"></div>
<div class="status">
{{ questionsAnswered }} out of {{ questions.length }} questions answered
</div>
</div>
<TransitionGroup name="fade">
<div
class="single-question"
v-for="(question, qi) in questions"
:key="question.q"
v-show="questionsAnswered === qi"
>
<div class="question">{{ question.q }}</div>
<div class="answers">
<div
class="answer"
v-for="answer in question.answers"
:key="answer.text"
@click.prevent="selectAnswer(answer.is_correct)"
>
{{ answer.text }}
</div>
</div>
</div>
</TransitionGroup>
</div>
</template>
<style scoped>
</style>
src/components/Result.vue
<script setup lang="ts">
import {computed} from "vue";
const props = withDefaults(
defineProps<{
results: Array<{
min: number
max: number
title: string
desc: string
}>
totalCorrect: number
}>(),
{}
)
const resultIndex = computed(() => {
let index = 0;
props.results.forEach((e, i) => {
if (e.min <= props.totalCorrect && e.max >= props.totalCorrect) {
index = 1;
}
})
return index;
})
</script>
<template>
<div class="result">
<div class="title">{{ results[resultIndex].title }}</div>
<div class="desc">
{{ results[resultIndex].desc }}
</div>
</div>
</template>
<style scoped>
</style>
src/assets/main.css
* {
box-sizing: border-box;
}
body {
font-size: 20px;
font-family: sans-serif;
padding-top: 20px;
background: #e6ecf1;
}
.ctr {
margin: 0 auto;
max-width: 600px;
width: 100%;
box-sizing: border-box;
position: relative;
}
.questions-ctr {
position: relative;
width: 100%;
}
.question {
width: 100%;
padding: 20px;
font-size: 32px;
font-weight: bold;
text-align: center;
background-color: #00ca8c;
color: #fff;
box-sizing: border-box;
}
.single-question {
position: relative;
width: 100%;
}
.answer {
border: 1px solid #8e959f;
padding: 20px;
font-size: 18px;
width: 100%;
background-color: #fff;
transition: 0.2s linear all;
}
.answer span {
display: inline-block;
margin-left: 5px;
font-size: 0.75em;
font-style: italic;
}
.progress {
height: 50px;
margin-top: 10px;
background-color: #ddd;
position: relative;
}
.bar {
height: 50px;
background-color: #ff6372;
transition: all 0.3s linear;
}
.status {
position: absolute;
top: 15px;
left: 0;
text-align: center;
color: #fff;
width: 100%;
}
.answer:not(.is-answered) {
cursor: pointer;
}
.answer:not(.is-answered):hover {
background-color: #8ce200;
border-color: #8ce200;
color: #fff;
}
.title {
width: 100%;
padding: 20px;
font-size: 32px;
font-weight: bold;
text-align: center;
background-color: #12cbc4;
color: #fff;
box-sizing: border-box;
}
.desc {
border: 1px solid #8e959f;
padding: 20px;
font-size: 18px;
width: 100%;
background-color: #fff;
transition: 0.4s linear all;
text-align: center;
}
.fade-enter-from {
opacity: 0;
}
.fade-enter-active {
transition: all 0.3s linear;
}
.fade-leave-active {
transition: all 0.3s linear;
opacity: 0;
position: absolute;
}
.fade-leave-to {
opacity: 0;
}
.reset-btn {
background-color: #ff6372;
border: 0;
font-size: 22px;
color: #fff;
padding: 10px 25px;
margin: 10px auto;
display: block;
}
.result{
width: 100%;
}
.reset-btn:active, .reset-btn:focus, .reset-btn:hover{
border: 0;
outline: 0;
}