feat: 학습 플로우 개편 (LearnView, FlashcardView, StatsView) + XP/레벨/뱃지 UI + 퀴즈 난이도 선택

This commit is contained in:
hyoseung930 2026-05-14 13:12:54 +09:00
parent 834f3fc737
commit 79983cd624
8 changed files with 713 additions and 57 deletions

View File

@ -1,11 +1,13 @@
<template> <template>
<nav class="nav"> <nav class="nav">
<span class="nav-logo">📚 English Study</span> <RouterLink to="/" class="nav-logo">📚 English Study</RouterLink>
<RouterLink to="/"></RouterLink> <RouterLink to="/learn">학습</RouterLink>
<RouterLink to="/words">단어</RouterLink> <RouterLink to="/flashcard">플래시카드</RouterLink>
<RouterLink to="/phrases">회화</RouterLink>
<RouterLink to="/quiz">퀴즈</RouterLink> <RouterLink to="/quiz">퀴즈</RouterLink>
<RouterLink to="/words">단어장</RouterLink>
<RouterLink to="/phrases">회화</RouterLink>
<RouterLink to="/grammar">문법</RouterLink> <RouterLink to="/grammar">문법</RouterLink>
<RouterLink to="/stats">통계</RouterLink>
</nav> </nav>
<RouterView /> <RouterView />
</template> </template>

View File

@ -21,7 +21,13 @@ export const grammarApi = {
} }
export const quizApi = { export const quizApi = {
generate: (count = 5) => api.get('/quiz/generate', { params: { count } }), generate: (count = 10, difficulty, studiedIds) => api.get('/quiz/generate', {
params: {
count,
difficulty: difficulty || undefined,
studied: studiedIds && studiedIds.length ? studiedIds.join(',') : undefined,
},
}),
saveResult: (data) => api.post('/quiz/result', data), saveResult: (data) => api.post('/quiz/result', data),
} }
@ -30,3 +36,11 @@ export const questApi = {
complete: (data) => api.post('/quest/complete', data), complete: (data) => api.post('/quest/complete', data),
getStreak: (session) => api.get('/quest/streak', { params: { session } }), getStreak: (session) => api.get('/quest/streak', { params: { session } }),
} }
export const progressApi = {
getStats: (session) => api.get('/progress/stats', { params: { session } }),
recordWord: (session_id, word_id) => api.post('/progress/word', { session_id, word_id }),
recordQuizAnswer: (data) => api.post('/progress/quiz-answer', data),
recordQuizComplete: (data) => api.post('/progress/quiz-complete', data),
getStudiedWords: (session, date) => api.get('/progress/studied-words', { params: { session, date } }),
}

View File

@ -5,9 +5,12 @@ export default createRouter({
history: createWebHistory(), history: createWebHistory(),
routes: [ routes: [
{ path: '/', component: HomeView }, { path: '/', component: HomeView },
{ path: '/learn', component: () => import('../views/LearnView.vue') },
{ path: '/words', component: () => import('../views/WordsView.vue') }, { path: '/words', component: () => import('../views/WordsView.vue') },
{ path: '/flashcard', component: () => import('../views/FlashcardView.vue') },
{ path: '/phrases', component: () => import('../views/PhrasesView.vue') }, { path: '/phrases', component: () => import('../views/PhrasesView.vue') },
{ path: '/quiz', component: () => import('../views/QuizView.vue') }, { path: '/quiz', component: () => import('../views/QuizView.vue') },
{ path: '/grammar', component: () => import('../views/GrammarView.vue') }, { path: '/grammar', component: () => import('../views/GrammarView.vue') },
{ path: '/stats', component: () => import('../views/StatsView.vue') },
], ],
}) })

150
src/views/FlashcardView.vue Normal file
View File

@ -0,0 +1,150 @@
<template>
<div class="container" style="max-width:640px">
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem">
<button class="btn btn-secondary" style="padding:0.4rem 0.8rem;font-size:0.82rem" @click="$router.push('/')"> </button>
<h1 class="page-title" style="margin:0">🃏 플래시카드</h1>
</div>
<div v-if="loading" class="loading">불러오는 중...</div>
<div v-else-if="!words.length" class="card" style="text-align:center;padding:3rem">
<div style="font-size:3rem">📭</div>
<p style="color:#718096;margin-top:1rem">단어 데이터가 없습니다</p>
</div>
<div v-else>
<!-- 모드 선택 -->
<div style="display:flex;gap:0.5rem;margin-bottom:1rem;flex-wrap:wrap">
<button class="btn" :class="mode==='daily'?'btn-primary':'btn-secondary'" @click="setMode('daily')">📅 오늘 단어</button>
<button class="btn" :class="mode==='all'?'btn-primary':'btn-secondary'" @click="setMode('all')">📚 전체</button>
<button class="btn" :class="mode==='wrong'?'btn-primary':'btn-secondary'" @click="setMode('wrong')"> 틀린 단어</button>
<button class="btn btn-secondary" style="margin-left:auto" @click="shuffle">🔀 섞기</button>
</div>
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1rem">
<div style="flex:1;background:#e2e8f0;border-radius:99px;height:6px">
<div style="background:#48bb78;height:6px;border-radius:99px;transition:width 0.3s"
:style="{ width: ((current+1)/words.length*100)+'%' }"></div>
</div>
<span style="font-size:0.85rem;color:#718096;white-space:nowrap">{{ current+1 }}/{{ words.length }}</span>
</div>
<div class="learn-card" :class="{ flipped }" @click="flipped=!flipped">
<div class="card-inner">
<div class="card-front card">
<div style="text-align:center;padding:2rem 1.5rem">
<div style="display:flex;gap:0.4rem;justify-content:center;margin-bottom:1rem">
<span class="badge badge-purple">{{ words[current]?.difficulty }}</span>
<span class="badge badge-blue">{{ words[current]?.category }}</span>
</div>
<div class="word-en" style="font-size:2.5rem">{{ words[current]?.english }}</div>
<div v-if="words[current]?.pronunciation" class="word-pron" style="margin-top:0.4rem">[{{ words[current].pronunciation }}]</div>
<div style="margin-top:2rem;color:#a0aec0;font-size:0.82rem">👆 클릭하면 확인</div>
</div>
</div>
<div class="card-back card">
<div style="text-align:center;padding:2rem 1.5rem;width:100%">
<div style="color:#a0aec0;font-size:1rem;margin-bottom:0.5rem">{{ words[current]?.english }}</div>
<div class="word-ko" style="font-size:2.2rem;font-weight:800;margin-bottom:1.5rem">{{ words[current]?.korean }}</div>
<div v-if="words[current]?.example_en" class="word-example" style="text-align:left;font-size:0.88rem;max-width:380px;margin:0 auto">
<div style="color:#4a5568">{{ words[current].example_en }}</div>
<div style="color:#a0aec0;margin-top:0.2rem">{{ words[current].example_ko }}</div>
</div>
</div>
</div>
</div>
</div>
<div style="display:flex;gap:1rem;margin-top:1.5rem">
<button class="btn btn-secondary" style="flex:1" :disabled="current===0" @click="prev"> 이전</button>
<button class="btn" :class="known[words[current]?.id]?'btn-primary':'btn-secondary'" style="flex:1" @click="markKnown">
{{ known[words[current]?.id] ? '✅ 알아요' : '알아요' }}
</button>
<button class="btn btn-primary" style="flex:1" @click="next">다음 </button>
</div>
<div style="margin-top:1rem;text-align:center;font-size:0.85rem;color:#718096">
알고 있는 단어: <strong style="color:#48bb78">{{ knownCount }}</strong> / {{ words.length }}
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { wordsApi, progressApi } from '../api'
import { useSessionStore } from '../stores/session'
const session = useSessionStore()
const allWords = ref([])
const dailyWords = ref([])
const wrongWords = ref([])
const words = ref([])
const loading = ref(true)
const current = ref(0)
const flipped = ref(false)
const mode = ref('daily')
const known = ref({})
const knownCount = computed(() => Object.values(known.value).filter(Boolean).length)
onMounted(async () => {
const [daily, all, stats] = await Promise.all([
wordsApi.getDaily(),
wordsApi.getAll(),
progressApi.getStats(session.id),
])
dailyWords.value = daily.data
allWords.value = all.data
wrongWords.value = (stats.data?.stats?.wrong_words || []).map(w => allWords.value.find(a => a.id === w.id)).filter(Boolean)
words.value = dailyWords.value
loading.value = false
})
const setMode = (m) => {
mode.value = m
current.value = 0
flipped.value = false
if (m === 'daily') words.value = dailyWords.value
else if (m === 'all') words.value = [...allWords.value].sort(() => Math.random() - 0.5).slice(0, 50)
else words.value = wrongWords.value.length ? wrongWords.value : dailyWords.value
}
const shuffle = () => {
words.value = [...words.value].sort(() => Math.random() - 0.5)
current.value = 0
flipped.value = false
}
const next = () => {
if (current.value < words.value.length - 1) {
flipped.value = false
setTimeout(() => { current.value++ }, 150)
}
}
const prev = () => {
if (current.value > 0) {
flipped.value = false
setTimeout(() => { current.value-- }, 150)
}
}
const markKnown = () => {
const id = words.value[current.value]?.id
if (id) known.value = { ...known.value, [id]: !known.value[id] }
}
</script>
<style scoped>
.learn-card { height: 320px; cursor: pointer; perspective: 1200px; margin-bottom: 0.5rem; }
.card-inner { position: relative; width: 100%; height: 100%; transition: transform 0.5s cubic-bezier(0.4,0,0.2,1); transform-style: preserve-3d; }
.learn-card.flipped .card-inner { transform: rotateY(180deg); }
.card-front, .card-back {
position: absolute; width: 100%; height: 100%;
backface-visibility: hidden; -webkit-backface-visibility: hidden;
display: flex; align-items: center; justify-content: center;
margin: 0 !important;
}
.card-back { transform: rotateY(180deg); }
</style>

View File

@ -1,19 +1,39 @@
<template> <template>
<div class="container"> <div class="container">
<div class="streak-box"> <!-- XP / 레벨 -->
<div class="streak-num">🔥 {{ streak.current_streak }}</div> <div class="level-box" style="margin-bottom:1rem">
<div class="streak-label"> 연속 학습 (최고 {{ streak.max_streak }})</div> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem">
<div style="font-weight:700;font-size:1rem">Lv.{{ progress.level }} <span style="font-size:0.82rem;font-weight:400;color:rgba(255,255,255,0.7)">{{ levelNames[Math.min(progress.level-1,levelNames.length-1)] }}</span></div>
<div style="font-size:0.82rem;opacity:0.8">{{ progress.xp }} / {{ progress.nextLevelXp }} XP</div>
</div>
<div style="background:rgba(255,255,255,0.25);border-radius:99px;height:10px">
<div style="background:white;border-radius:99px;height:10px;transition:width 0.6s"
:style="{ width: (xpPct) + '%' }"></div>
</div>
</div> </div>
<!-- 스트릭 + 콤보 -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem">
<div class="streak-box" style="margin-bottom:0">
<div class="streak-num">🔥 {{ streak.current_streak }}</div>
<div class="streak-label"> 연속 (최고 {{ streak.max_streak }})</div>
</div>
<div class="streak-box" style="background:linear-gradient(135deg,#f6ad55,#ed8936);margin-bottom:0">
<div class="streak-num"> {{ progress.max_combo }}</div>
<div class="streak-label">최고 콤보</div>
</div>
</div>
<!-- 오늘의 퀘스트 -->
<div class="card" style="margin-bottom:1.5rem"> <div class="card" style="margin-bottom:1.5rem">
<h3 style="margin-bottom:1rem;font-size:1rem;color:#4a5568">오늘의 퀘스트</h3> <h3 style="margin-bottom:1rem;font-size:1rem;color:#4a5568">오늘의 퀘스트</h3>
<div class="quest-item"> <div class="quest-item">
<div class="quest-check" :class="{ done: quest.word_done }"></div> <div class="quest-check" :class="{ done: quest.word_done }"></div>
<div> <div>
<div style="font-weight:600">단어 학습</div> <div style="font-weight:600">단어 학습</div>
<div style="font-size:0.82rem;color:#718096">오늘의 단어 5 확인하기</div> <div style="font-size:0.82rem;color:#718096">오늘의 단어 10 학습하기</div>
</div> </div>
<RouterLink v-if="!quest.word_done" to="/words" class="btn btn-primary" style="margin-left:auto;font-size:0.8rem;padding:0.4rem 0.8rem">시작</RouterLink> <RouterLink v-if="!quest.word_done" to="/learn" class="btn btn-primary" style="margin-left:auto;font-size:0.8rem;padding:0.4rem 0.8rem">시작</RouterLink>
<span v-else class="badge badge-green" style="margin-left:auto">완료</span> <span v-else class="badge badge-green" style="margin-left:auto">완료</span>
</div> </div>
<div class="quest-item"> <div class="quest-item">
@ -29,48 +49,93 @@
<div class="quest-check" :class="{ done: quest.quiz_done }"></div> <div class="quest-check" :class="{ done: quest.quiz_done }"></div>
<div> <div>
<div style="font-weight:600">퀴즈</div> <div style="font-weight:600">퀴즈</div>
<div style="font-size:0.82rem;color:#718096">5문제 풀기 <span v-if="quest.quiz_score !== null">({{ quest.quiz_score }})</span></div> <div style="font-size:0.82rem;color:#718096">10문제 풀기 <span v-if="quest.quiz_score !== null">({{ quest.quiz_score }})</span></div>
</div> </div>
<RouterLink v-if="!quest.quiz_done" to="/quiz" class="btn btn-primary" style="margin-left:auto;font-size:0.8rem;padding:0.4rem 0.8rem">시작</RouterLink> <RouterLink v-if="!quest.quiz_done" to="/quiz" class="btn btn-primary" style="margin-left:auto;font-size:0.8rem;padding:0.4rem 0.8rem">시작</RouterLink>
<span v-else class="badge badge-green" style="margin-left:auto">완료</span> <span v-else class="badge badge-green" style="margin-left:auto">완료</span>
</div> </div>
</div> </div>
<h2 class="page-title">오늘의 단어</h2> <!-- 뱃지 -->
<div class="card-grid"> <div v-if="earnedBadges.length" class="card" style="margin-bottom:1.5rem">
<div v-for="word in dailyWords" :key="word.id" class="card word-card"> <h3 style="margin-bottom:1rem;font-size:1rem;color:#4a5568"> 뱃지 <span style="font-size:0.8rem;color:#a0aec0">({{ earnedBadges.length }})</span></h3>
<div class="word-en">{{ word.english }}</div> <div style="display:flex;flex-wrap:wrap;gap:0.5rem">
<div v-if="word.pronunciation" class="word-pron">[{{ word.pronunciation }}]</div> <div v-for="b in earnedBadges" :key="b.id" class="badge-card" :title="b.desc">
<div class="word-ko">{{ word.korean }}</div> <span style="font-size:1.4rem">{{ b.emoji }}</span>
<div v-if="word.example_en" class="word-example"> <span style="font-size:0.75rem;font-weight:600">{{ b.name }}</span>
{{ word.example_en }}<br/>
<span style="color:#a0aec0">{{ word.example_ko }}</span>
</div> </div>
</div> </div>
<div v-if="!dailyWords.length" class="empty">단어 데이터가 없습니다</div> </div>
<!-- 빠른 메뉴 -->
<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:1rem;margin-bottom:1.5rem">
<RouterLink to="/learn" class="quick-card">
<span style="font-size:1.8rem">📖</span>
<div style="font-weight:700">오늘의 학습</div>
<div style="font-size:0.78rem;color:#718096">단어 10 순차 학습</div>
</RouterLink>
<RouterLink to="/flashcard" class="quick-card">
<span style="font-size:1.8rem">🃏</span>
<div style="font-weight:700">플래시카드</div>
<div style="font-size:0.78rem;color:#718096">카드 뒤집기 복습</div>
</RouterLink>
<RouterLink to="/quiz" class="quick-card">
<span style="font-size:1.8rem">🎯</span>
<div style="font-weight:700">퀴즈</div>
<div style="font-size:0.78rem;color:#718096">난이도 선택 퀴즈</div>
</RouterLink>
<RouterLink to="/stats" class="quick-card">
<span style="font-size:1.8rem">📊</span>
<div style="font-weight:700">통계</div>
<div style="font-size:0.78rem;color:#718096">학습 현황 확인</div>
</RouterLink>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { wordsApi, questApi } from '../api' import { questApi, progressApi } from '../api'
import { useSessionStore } from '../stores/session' import { useSessionStore } from '../stores/session'
const session = useSessionStore() const session = useSessionStore()
const dailyWords = ref([])
const quest = ref({ word_done: false, phrase_done: false, quiz_done: false, quiz_score: null }) const quest = ref({ word_done: false, phrase_done: false, quiz_done: false, quiz_score: null })
const streak = ref({ current_streak: 0, max_streak: 0 }) const streak = ref({ current_streak: 0, max_streak: 0 })
const progress = ref({ level: 1, xp: 0, nextLevelXp: 100, xpProgress: 0, xpRange: 100, max_combo: 0, badges: [], allBadges: [] })
const levelNames = ['입문자','초보자','학습자','중급자','숙련자','전문가','고수','마스터','엘리트','챔피언','전설']
const xpPct = computed(() => Math.min(100, Math.round((progress.value.xpProgress / progress.value.xpRange) * 100)) || 0)
const earnedBadges = computed(() => (progress.value.allBadges || []).filter(b => b.earned))
onMounted(async () => { onMounted(async () => {
const [w, q, s] = await Promise.all([ const [q, s, p] = await Promise.all([
wordsApi.getDaily(),
questApi.getToday(session.id), questApi.getToday(session.id),
questApi.getStreak(session.id), questApi.getStreak(session.id),
progressApi.getStats(session.id),
]) ])
dailyWords.value = w.data
quest.value = q.data quest.value = q.data
streak.value = s.data streak.value = s.data
progress.value = p.data
}) })
</script> </script>
<style scoped>
.level-box {
background: linear-gradient(135deg, #667eea, #764ba2);
color: white; border-radius: 12px; padding: 1.2rem 1.5rem;
}
.quick-card {
background: white; border-radius: 12px; padding: 1.2rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
display: flex; flex-direction: column; gap: 0.3rem;
text-decoration: none; color: #2d3748;
transition: transform 0.2s, box-shadow 0.2s;
}
.quick-card:hover { transform: translateY(-2px); box-shadow: 0 6px 16px rgba(0,0,0,0.1); }
.badge-card {
display: flex; flex-direction: column; align-items: center; gap: 0.2rem;
background: #f7fafc; border: 1px solid #e2e8f0; border-radius: 10px;
padding: 0.6rem 0.8rem; min-width: 60px; text-align: center;
}
</style>

189
src/views/LearnView.vue Normal file
View File

@ -0,0 +1,189 @@
<template>
<div class="container" style="max-width:640px">
<div v-if="loading" class="card" style="text-align:center;padding:4rem">
<div class="loading">단어 불러오는 ...</div>
</div>
<div v-else-if="!started" class="card start-card">
<div style="font-size:4rem;margin-bottom:1rem">📖</div>
<h2 style="margin-bottom:0.5rem">오늘의 단어 학습</h2>
<p style="color:#718096;margin-bottom:0.5rem"> <strong>{{ words.length }}</strong> 단어를 학습합니다</p>
<p style="color:#a0aec0;font-size:0.85rem;margin-bottom:2rem">카드를 클릭하면 뜻이 나타납니다</p>
<button class="btn btn-primary" style="font-size:1rem;padding:0.8rem 2.5rem" @click="start">학습 시작</button>
</div>
<div v-else-if="!finished">
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem">
<button class="btn btn-secondary" style="padding:0.4rem 0.8rem;font-size:0.82rem" @click="$router.push('/')"> </button>
<div style="flex:1;background:#e2e8f0;border-radius:99px;height:8px;overflow:hidden">
<div class="progress-bar-fill" :style="{ width: ((current) / words.length * 100) + '%' }"></div>
</div>
<span style="font-size:0.85rem;color:#718096;white-space:nowrap">{{ current + 1 }} / {{ words.length }}</span>
</div>
<div class="learn-card" :class="{ flipped: flipped }" @click="flip">
<div class="card-inner">
<div class="card-front card">
<div class="card-badges">
<span class="badge badge-purple">{{ words[current]?.difficulty }}</span>
<span class="badge badge-blue">{{ words[current]?.category }}</span>
</div>
<div style="text-align:center;padding:2rem 1rem">
<div class="word-en" style="font-size:2.5rem;margin-bottom:0.5rem">{{ words[current]?.english }}</div>
<div v-if="words[current]?.pronunciation" class="word-pron" style="font-size:1rem">[{{ words[current].pronunciation }}]</div>
<div style="margin-top:2.5rem;color:#a0aec0;font-size:0.85rem">👆 클릭해서 확인</div>
</div>
</div>
<div class="card-back card">
<div style="text-align:center;padding:2rem 1rem;width:100%">
<div style="font-size:1.3rem;color:#a0aec0;margin-bottom:0.4rem">{{ words[current]?.english }}</div>
<div class="word-ko" style="font-size:2.2rem;font-weight:800;margin-bottom:1.5rem;color:#2d3748">{{ words[current]?.korean }}</div>
<div v-if="words[current]?.example_en" class="word-example" style="text-align:left;font-size:0.88rem;max-width:400px;margin:0 auto">
<div style="color:#4a5568;margin-bottom:0.3rem">{{ words[current].example_en }}</div>
<div style="color:#a0aec0">{{ words[current].example_ko }}</div>
</div>
</div>
</div>
</div>
</div>
<div style="display:flex;gap:1rem;margin-top:1.5rem">
<button class="btn btn-secondary" style="flex:1" :disabled="current === 0" @click="prev"> 이전</button>
<button class="btn btn-primary" style="flex:2;font-size:1rem" @click="next">
{{ current < words.length - 1 ? '다음 →' : '🎉 학습 완료!' }}
</button>
</div>
<transition name="xp-fade">
<div v-if="xpGained" class="xp-toast">+{{ xpGained }} XP</div>
</transition>
</div>
<div v-else class="card" style="text-align:center;padding:3rem">
<div style="font-size:4rem;margin-bottom:1rem">🎉</div>
<h2 style="margin-bottom:0.5rem">학습 완료!</h2>
<p style="color:#718096;margin-bottom:1.5rem">오늘 <strong>{{ words.length }}</strong> 단어를 모두 학습했어요</p>
<div class="xp-result-box">
<div style="font-size:2rem;font-weight:800;color:#48bb78">+{{ totalXp }} XP</div>
<div style="font-size:0.85rem;color:#718096;margin-top:0.3rem">획득한 경험치</div>
</div>
<div v-if="newBadges.length" style="margin-bottom:1.5rem">
<div style="font-size:0.85rem;color:#718096;margin-bottom:0.5rem">🏅 뱃지 획득!</div>
<div v-for="b in newBadges" :key="b" class="badge-item">
{{ badgeMap[b]?.emoji }} {{ badgeMap[b]?.name }}
</div>
</div>
<div style="display:flex;gap:0.75rem;justify-content:center;flex-wrap:wrap">
<button class="btn btn-secondary" @click="$router.push('/flashcard')">🃏 플래시카드</button>
<button class="btn btn-primary" @click="$router.push('/quiz')">퀴즈 풀기 </button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { wordsApi, progressApi, questApi } from '../api'
import { useSessionStore } from '../stores/session'
const router = useRouter()
const session = useSessionStore()
const words = ref([])
const loading = ref(true)
const started = ref(false)
const finished = ref(false)
const current = ref(0)
const flipped = ref(false)
const xpGained = ref(0)
const totalXp = ref(0)
const newBadges = ref([])
const badgeMap = {
first_study: { emoji: '🌱', name: '첫 걸음' },
words_50: { emoji: '📚', name: '단어 50' },
words_100: { emoji: '🎓', name: '단어 100' },
words_500: { emoji: '👑', name: '단어 500' },
}
let xpTimer = null
onMounted(async () => {
const res = await wordsApi.getDaily()
words.value = res.data
loading.value = false
})
const start = () => { started.value = true }
const flip = () => { flipped.value = !flipped.value }
const showXp = (xp) => {
xpGained.value = xp
if (xpTimer) clearTimeout(xpTimer)
xpTimer = setTimeout(() => { xpGained.value = 0 }, 1500)
}
const next = async () => {
const word = words.value[current.value]
try {
const res = await progressApi.recordWord(session.id, word.id)
const gained = res.data?.xpGained || 10
showXp(gained)
totalXp.value += gained
const nb = res.data?.newBadges || []
for (const b of nb) if (!newBadges.value.includes(b)) newBadges.value.push(b)
} catch (e) { /* ignore */ }
flipped.value = false
if (current.value < words.value.length - 1) {
setTimeout(() => { current.value++ }, 200)
} else {
finished.value = true
questApi.complete({ session_id: session.id, type: 'word' }).catch(() => {})
}
}
const prev = () => {
if (current.value > 0) {
flipped.value = false
setTimeout(() => { current.value-- }, 200)
}
}
</script>
<style scoped>
.start-card { text-align: center; padding: 3rem; }
.progress-bar-fill { height: 100%; background: linear-gradient(90deg, #48bb78, #38a169); border-radius: 99px; transition: width 0.4s; }
.card-badges { position: absolute; top: 1rem; right: 1rem; display: flex; gap: 0.3rem; }
.learn-card { height: 320px; cursor: pointer; perspective: 1200px; margin-bottom: 0.5rem; }
.card-inner { position: relative; width: 100%; height: 100%; transition: transform 0.5s cubic-bezier(0.4,0,0.2,1); transform-style: preserve-3d; }
.learn-card.flipped .card-inner { transform: rotateY(180deg); }
.card-front, .card-back {
position: absolute; width: 100%; height: 100%;
backface-visibility: hidden; -webkit-backface-visibility: hidden;
display: flex; align-items: center; justify-content: center;
margin: 0 !important; overflow: hidden;
}
.card-back { transform: rotateY(180deg); }
.xp-toast {
position: fixed; top: 80px; left: 50%; transform: translateX(-50%);
background: linear-gradient(135deg, #48bb78, #38a169); color: white;
padding: 0.5rem 1.8rem; border-radius: 999px;
font-weight: 700; font-size: 1.2rem;
box-shadow: 0 4px 12px rgba(72,187,120,0.4);
pointer-events: none; z-index: 999;
}
.xp-fade-enter-active { animation: xpPop 1.5s ease forwards; }
@keyframes xpPop {
0% { opacity: 0; transform: translateX(-50%) translateY(10px) scale(0.8); }
20% { opacity: 1; transform: translateX(-50%) translateY(0) scale(1.1); }
60% { opacity: 1; transform: translateX(-50%) translateY(-5px) scale(1); }
100% { opacity: 0; transform: translateX(-50%) translateY(-30px) scale(0.9); }
}
.xp-result-box { background: #f0fff4; border: 2px solid #c6f6d5; border-radius: 12px; padding: 1.2rem; margin-bottom: 1.5rem; }
.badge-item { display: inline-block; background: #fefcbf; border: 1px solid #f6e05e; border-radius: 8px; padding: 0.35rem 0.8rem; margin: 0.2rem; font-size: 0.85rem; font-weight: 600; }
</style>

View File

@ -1,88 +1,198 @@
<template> <template>
<div class="container" style="max-width:600px"> <div class="container" style="max-width:600px">
<h1 class="page-title">퀴즈</h1>
<div v-if="!started" class="card" style="text-align:center;padding:3rem"> <!-- 난이도 선택 화면 -->
<div v-if="!started && !finished" class="card" style="text-align:center;padding:2rem">
<div style="font-size:3rem;margin-bottom:1rem">🎯</div> <div style="font-size:3rem;margin-bottom:1rem">🎯</div>
<h2 style="margin-bottom:0.5rem">단어 퀴즈</h2> <h2 style="margin-bottom:0.5rem">단어 퀴즈</h2>
<p style="color:#718096;margin-bottom:2rem">영어 단어의 뜻을 맞춰보세요!</p> <p style="color:#718096;margin-bottom:1.5rem">영어 단어의 뜻을 맞춰보세요!</p>
<button class="btn btn-primary" style="font-size:1rem;padding:0.8rem 2rem" @click="startQuiz">시작하기</button>
<div style="text-align:left;margin-bottom:1.5rem">
<div style="font-size:0.85rem;font-weight:600;color:#4a5568;margin-bottom:0.6rem">난이도</div>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap">
<button v-for="d in difficulties" :key="d.value"
class="btn" :class="difficulty === d.value ? 'btn-primary' : 'btn-secondary'"
@click="difficulty = d.value">
{{ d.emoji }} {{ d.label }}
</button>
</div>
</div>
<div style="text-align:left;margin-bottom:1.5rem">
<div style="font-size:0.85rem;font-weight:600;color:#4a5568;margin-bottom:0.6rem">출제 방식</div>
<div style="display:flex;gap:0.5rem">
<button class="btn" :class="useStudied ? 'btn-primary' : 'btn-secondary'" @click="useStudied = true">
📖 오늘 학습한 단어 위주
</button>
<button class="btn" :class="!useStudied ? 'btn-primary' : 'btn-secondary'" @click="useStudied = false">
🎲 전체 랜덤
</button>
</div>
<div v-if="useStudied && studiedIds.length < 4" style="margin-top:0.5rem;font-size:0.8rem;color:#e53e3e">
오늘 학습한 단어가 {{ studiedIds.length }}개입니다. 4 이상 학습하면 활성화됩니다.
</div>
</div>
<div style="text-align:left;margin-bottom:2rem">
<div style="font-size:0.85rem;font-weight:600;color:#4a5568;margin-bottom:0.6rem">문제 </div>
<div style="display:flex;gap:0.5rem">
<button v-for="n in [5, 10, 20]" :key="n"
class="btn" :class="count === n ? 'btn-primary' : 'btn-secondary'"
@click="count = n">{{ n }}문제</button>
</div>
</div>
<button class="btn btn-primary" style="font-size:1rem;padding:0.8rem 3rem;width:100%" @click="startQuiz">
🚀 퀴즈 시작
</button>
</div> </div>
<div v-else-if="!finished"> <!-- 퀴즈 진행 -->
<div class="quiz-progress"> <div v-else-if="started && !finished">
<div style="display:flex;align-items:center;gap:0.8rem;margin-bottom:0.5rem">
<div v-if="combo >= 2" class="combo-badge">🔥 {{ combo }} 콤보!</div>
<div style="flex:1" />
<span style="font-size:0.85rem;color:#718096">{{ current + 1 }} / {{ questions.length }}</span>
</div>
<div class="quiz-progress" style="margin-bottom:1.2rem">
<div class="quiz-progress-bar" :style="{ width: ((current) / questions.length * 100) + '%' }"></div> <div class="quiz-progress-bar" :style="{ width: ((current) / questions.length * 100) + '%' }"></div>
</div> </div>
<div style="text-align:right;font-size:0.85rem;color:#718096;margin-bottom:0.5rem">{{ current + 1 }} / {{ questions.length }}</div>
<div class="card"> <div class="card">
<div v-if="questions[current]?.difficulty" style="text-align:center;margin-bottom:0.5rem">
<span class="badge" :class="diffBadge(questions[current].difficulty)">{{ questions[current].difficulty }}</span>
</div>
<div class="quiz-question">{{ questions[current]?.english }}</div> <div class="quiz-question">{{ questions[current]?.english }}</div>
<div v-if="questions[current]?.pronunciation" class="quiz-sub">[{{ questions[current].pronunciation }}]</div> <div v-if="questions[current]?.pronunciation" class="quiz-sub">[{{ questions[current].pronunciation }}]</div>
<div class="quiz-choices"> <div class="quiz-choices">
<button v-for="choice in questions[current]?.choices" :key="choice" <button v-for="choice in questions[current]?.choices" :key="choice"
class="quiz-choice" class="quiz-choice"
:class="answered ? (choice === questions[current].answer ? 'correct' : (choice === selected ? 'wrong' : '')) : ''" :class="answered ? (choice === questions[current].answer ? 'correct' : (choice === selectedAnswer ? 'wrong' : '')) : ''"
:disabled="answered" :disabled="answered"
@click="answer(choice)"> @click="answer(choice)">
{{ choice }} {{ choice }}
</button> </button>
</div> </div>
<div v-if="answered" style="text-align:center;margin-top:1.5rem"> <div v-if="answered" style="text-align:center;margin-top:1.5rem">
<div style="margin-bottom:1rem;font-weight:600" :style="{ color: selected === questions[current].answer ? '#38a169' : '#e53e3e' }"> <div class="answer-feedback" :class="selectedAnswer === questions[current].answer ? 'correct-fb' : 'wrong-fb'">
{{ selected === questions[current].answer ? '✅ 정답!' : '❌ 오답' }} {{ selectedAnswer === questions[current].answer ? '✅ 정답!' : '❌ 오답 — 정답: ' + questions[current].answer }}
</div> </div>
<button class="btn btn-primary" @click="next">{{ current < questions.length - 1 ? '다음' : '결과 보기' }}</button> <div v-if="lastXp" style="font-size:0.9rem;color:#48bb78;font-weight:600;margin-bottom:0.8rem">+{{ lastXp }} XP</div>
<button class="btn btn-primary" @click="nextQ">{{ current < questions.length - 1 ? '다음 ' : '결과 보기' }}</button>
</div> </div>
</div> </div>
</div> </div>
<div v-else class="card" style="text-align:center;padding:3rem"> <!-- 결과 화면 -->
<div style="font-size:3rem;margin-bottom:1rem">{{ score === questions.length ? '🏆' : score >= questions.length / 2 ? '👏' : '💪' }}</div> <div v-else-if="finished" class="card" style="text-align:center;padding:2.5rem">
<h2 style="margin-bottom:0.5rem">{{ score }} / {{ questions.length }} 정답</h2> <div style="font-size:3.5rem;margin-bottom:1rem">{{ score === questions.length ? '🏆' : score >= questions.length / 2 ? '👏' : '💪' }}</div>
<p style="color:#718096;margin-bottom:2rem">정확도 {{ Math.round(score / questions.length * 100) }}%</p> <h2 style="margin-bottom:0.3rem">{{ score }} / {{ questions.length }} 정답</h2>
<p style="color:#718096;margin-bottom:0.5rem">정확도 {{ Math.round(score / questions.length * 100) }}%</p>
<div class="xp-result-box">
<div style="font-size:1.8rem;font-weight:800;color:#48bb78">+{{ totalXp }} XP</div>
<div style="font-size:0.82rem;color:#718096;margin-top:0.2rem">최고 콤보: {{ maxCombo }}🔥</div>
</div>
<div v-if="earnedBadges.length" style="margin-bottom:1.5rem">
<div style="font-size:0.85rem;color:#718096;margin-bottom:0.5rem">🏅 뱃지!</div>
<div v-for="b in earnedBadges" :key="b" class="badge-item">{{ badgeMap[b]?.emoji }} {{ badgeMap[b]?.name }}</div>
</div>
<div style="display:flex;gap:0.75rem;justify-content:center"> <div style="display:flex;gap:0.75rem;justify-content:center">
<button class="btn btn-secondary" @click="reset">다시 풀기</button> <button class="btn btn-secondary" @click="reset">다시 풀기</button>
<RouterLink to="/" class="btn btn-primary">홈으로</RouterLink> <button class="btn btn-primary" @click="$router.push('/')">홈으로</button>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { ref, onMounted } from 'vue'
import { RouterLink } from 'vue-router' import { useRouter } from 'vue-router'
import { quizApi, questApi } from '../api' import { quizApi, questApi, progressApi } from '../api'
import { useSessionStore } from '../stores/session' import { useSessionStore } from '../stores/session'
const router = useRouter()
const session = useSessionStore() const session = useSessionStore()
const started = ref(false) const started = ref(false)
const finished = ref(false) const finished = ref(false)
const questions = ref([]) const questions = ref([])
const current = ref(0) const current = ref(0)
const selected = ref(null) const selectedAnswer = ref(null)
const answered = ref(false) const answered = ref(false)
const score = ref(0) const score = ref(0)
const difficulty = ref('')
const useStudied = ref(true)
const count = ref(10)
const studiedIds = ref([])
const combo = ref(0)
const maxCombo = ref(0)
const totalXp = ref(0)
const lastXp = ref(0)
const earnedBadges = ref([])
const difficulties = [
{ value: '', label: '전체', emoji: '🌐' },
{ value: 'beginner', label: '초급', emoji: '🌱' },
{ value: 'intermediate', label: '중급', emoji: '📚' },
{ value: 'advanced', label: '고급', emoji: '🎓' },
]
const badgeMap = {
quiz_perfect: { emoji: '🏆', name: '만점왕' },
combo_5: { emoji: '⚡', name: '5콤보' },
combo_10: { emoji: '🎯', name: '10콤보' },
}
const diffBadge = (d) => d === 'beginner' ? 'badge-green' : d === 'intermediate' ? 'badge-blue' : 'badge-purple'
onMounted(async () => {
try {
const res = await progressApi.getStudiedWords(session.id)
studiedIds.value = res.data || []
} catch (e) { /* ignore */ }
})
const startQuiz = async () => { const startQuiz = async () => {
const res = await quizApi.generate(5) const ids = useStudied.value && studiedIds.value.length >= 4 ? studiedIds.value : []
const res = await quizApi.generate(count.value, difficulty.value || undefined, ids)
questions.value = res.data questions.value = res.data
started.value = true started.value = true
} }
const answer = (choice) => { const answer = async (choice) => {
selected.value = choice selectedAnswer.value = choice
answered.value = true answered.value = true
if (choice === questions.value[current.value].answer) score.value++ const q = questions.value[current.value]
const isCorrect = choice === q.answer
if (isCorrect) score.value++
try {
const res = await progressApi.recordQuizAnswer({
session_id: session.id,
word_id: q.id,
is_correct: isCorrect,
word: { english: q.english, korean: q.korean },
})
lastXp.value = res.data?.xpGained || 0
totalXp.value += lastXp.value
combo.value = res.data?.progress?.combo || 0
if (combo.value > maxCombo.value) maxCombo.value = combo.value
const nb = res.data?.newBadges || []
for (const b of nb) if (!earnedBadges.value.includes(b)) earnedBadges.value.push(b)
} catch (e) { /* ignore */ }
} }
const next = () => { const nextQ = () => {
if (current.value < questions.value.length - 1) { lastXp.value = 0
if (current.value < questions.length - 1) {
current.value++ current.value++
answered.value = false answered.value = false
selected.value = null selectedAnswer.value = null
} else { } else {
finished.value = true finished.value = true
quizApi.saveResult({ session_id: session.id, score: score.value, total: questions.value.length }) quizApi.saveResult({ session_id: session.id, score: score.value, total: questions.value.length }).catch(() => {})
questApi.complete({ session_id: session.id, type: 'quiz', score: score.value }) questApi.complete({ session_id: session.id, type: 'quiz', score: score.value }).catch(() => {})
progressApi.recordQuizComplete({ session_id: session.id, score: score.value, total: questions.value.length }).catch(() => {})
} }
} }
@ -92,6 +202,26 @@ const reset = () => {
current.value = 0 current.value = 0
score.value = 0 score.value = 0
answered.value = false answered.value = false
selected.value = null selectedAnswer.value = null
combo.value = 0
totalXp.value = 0
lastXp.value = 0
} }
</script> </script>
<style scoped>
.combo-badge {
background: linear-gradient(135deg, #f6ad55, #ed8936);
color: white; font-weight: 700; font-size: 0.85rem;
padding: 0.25rem 0.8rem; border-radius: 999px;
animation: comboPulse 0.5s ease;
}
@keyframes comboPulse {
0% { transform: scale(0.8); } 50% { transform: scale(1.15); } 100% { transform: scale(1); }
}
.answer-feedback { font-weight: 700; margin-bottom: 0.5rem; font-size: 1rem; padding: 0.6rem; border-radius: 8px; }
.correct-fb { background: #f0fff4; color: #276749; }
.wrong-fb { background: #fff5f5; color: #c53030; }
.xp-result-box { background: #f0fff4; border: 2px solid #c6f6d5; border-radius: 12px; padding: 1rem; margin: 1rem 0 1.5rem; }
.badge-item { display: inline-block; background: #fefcbf; border: 1px solid #f6e05e; border-radius: 8px; padding: 0.3rem 0.7rem; margin: 0.2rem; font-size: 0.82rem; font-weight: 600; }
</style>

103
src/views/StatsView.vue Normal file
View File

@ -0,0 +1,103 @@
<template>
<div class="container" style="max-width:700px">
<h1 class="page-title">학습 통계</h1>
<div v-if="loading" class="loading">불러오는 중...</div>
<div v-else>
<!-- 핵심 수치 -->
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:1rem;margin-bottom:1.5rem">
<div class="stat-card">
<div class="stat-num">{{ stats.stats?.total_words_studied || 0 }}</div>
<div class="stat-label"> 학습 단어</div>
</div>
<div class="stat-card">
<div class="stat-num">{{ stats.stats?.total_quizzes || 0 }}</div>
<div class="stat-label"> 퀴즈 횟수</div>
</div>
<div class="stat-card">
<div class="stat-num">{{ accuracy }}%</div>
<div class="stat-label">평균 정확도</div>
</div>
</div>
<!-- XP / 레벨 -->
<div class="card" style="margin-bottom:1.5rem">
<h3 style="margin-bottom:1rem;font-size:1rem;color:#4a5568">레벨 & XP</h3>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem">
<span style="font-weight:700">Lv.{{ stats.level }} {{ levelNames[Math.min((stats.level||1)-1,levelNames.length-1)] }}</span>
<span style="font-size:0.85rem;color:#718096">{{ stats.xp }} XP</span>
</div>
<div style="background:#e2e8f0;border-radius:99px;height:12px">
<div style="background:linear-gradient(90deg,#667eea,#764ba2);border-radius:99px;height:12px;transition:width 0.6s"
:style="{ width: xpPct + '%' }"></div>
</div>
<div style="font-size:0.8rem;color:#a0aec0;margin-top:0.4rem">다음 레벨까지 {{ stats.xpToNextLevel }}XP</div>
</div>
<!-- 뱃지 전체 -->
<div class="card" style="margin-bottom:1.5rem">
<h3 style="margin-bottom:1rem;font-size:1rem;color:#4a5568">뱃지 컬렉션 {{ earnedCount }}/{{ stats.allBadges?.length }}</h3>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:0.75rem">
<div v-for="b in stats.allBadges" :key="b.id"
class="badge-full-card" :class="{ locked: !b.earned }" :title="b.desc">
<div style="font-size:2rem">{{ b.emoji }}</div>
<div style="font-size:0.75rem;font-weight:700;margin-top:0.2rem">{{ b.name }}</div>
<div style="font-size:0.68rem;color:#718096;margin-top:0.1rem">{{ b.desc }}</div>
<div v-if="!b.earned" class="lock-overlay">🔒</div>
</div>
</div>
</div>
<!-- 틀린 단어 TOP -->
<div v-if="wrongWords.length" class="card" style="margin-bottom:1.5rem">
<h3 style="margin-bottom:1rem;font-size:1rem;color:#4a5568">자주 틀리는 단어 TOP {{ wrongWords.length }}</h3>
<div v-for="(w, i) in wrongWords" :key="w.id" style="display:flex;align-items:center;gap:1rem;padding:0.6rem 0;border-bottom:1px solid #f7fafc">
<div style="width:24px;height:24px;background:#fed7d7;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:0.75rem;font-weight:700;color:#c53030">{{ i+1 }}</div>
<div style="flex:1">
<div style="font-weight:700;color:#2b6cb0">{{ w.english }}</div>
<div style="font-size:0.85rem;color:#718096">{{ w.korean }}</div>
</div>
<div style="font-size:0.82rem;color:#e53e3e;font-weight:600">{{ w.count }} 오답</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { progressApi } from '../api'
import { useSessionStore } from '../stores/session'
const session = useSessionStore()
const stats = ref({ level:1, xp:0, nextLevelXp:100, xpToNextLevel:100, xpProgress:0, xpRange:100, stats:{}, allBadges:[] })
const loading = ref(true)
const levelNames = ['입문자','초보자','학습자','중급자','숙련자','전문가','고수','마스터','엘리트','챔피언','전설']
const xpPct = computed(() => Math.min(100, Math.round(((stats.value.xpProgress||0) / (stats.value.xpRange||1)) * 100)))
const accuracy = computed(() => {
const s = stats.value.stats
if (!s || !s.total_quizzes) return 0
return Math.round((s.total_correct / s.total_quizzes) * 100)
})
const earnedCount = computed(() => (stats.value.allBadges||[]).filter(b=>b.earned).length)
const wrongWords = computed(() => (stats.value.stats?.wrong_words || []).slice(0, 10))
onMounted(async () => {
const res = await progressApi.getStats(session.id)
stats.value = res.data
loading.value = false
})
</script>
<style scoped>
.stat-card { background:white; border-radius:12px; padding:1.2rem; text-align:center; box-shadow:0 2px 8px rgba(0,0,0,0.06); }
.stat-num { font-size:2rem; font-weight:800; color:#2b6cb0; }
.stat-label { font-size:0.8rem; color:#718096; margin-top:0.2rem; }
.badge-full-card {
background:#f7fafc; border:1px solid #e2e8f0; border-radius:12px;
padding:0.8rem 0.5rem; text-align:center; position:relative; overflow:hidden;
}
.badge-full-card.locked { opacity:0.4; filter:grayscale(1); }
.lock-overlay { position:absolute; top:4px; right:6px; font-size:0.9rem; }
</style>