import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { getStudyQueue, recordReview, generateReadingPassage, completeVocabSession } from '@/services/vocabularyService.js' import { useAuthStore } from '@/stores/auth.js' import { useBadgeCelebrationStore } from '@/stores/badgeCelebration.js' import { VOCAB_PRACTICE_MODE_IDS } from '@/constants/vocabPracticeModes.js' import { normalizeVocabAnswer } from '@/utils/vocabAnswer.js' import { speakEnglish } from '@/utils/vocabSpeech.js' const READING_BATCH = 5 export function useVocabPractice() { const route = useRoute() const router = useRouter() const auth = useAuthStore() const sessionStartedAt = ref(Date.now()) const sessionReported = ref(false) const sessionXpEarned = ref(null) const topicId = computed(() => Number(route.params.topicId)) const currentMode = computed(() => { const m = route.query.mode || 'flashcard' return VOCAB_PRACTICE_MODE_IDS.includes(m) ? m : 'flashcard' }) const loading = ref(true) const loadError = ref('') const topicName = ref('') const dueCount = ref(null) const queue = ref([]) const queueIndex = ref(0) const doneCount = ref(0) const correctCount = ref(0) const completed = ref(false) const reviewing = ref(false) const cardFlipped = ref(false) const typingInput = ref('') const typingResult = ref(null) const typingStageRef = ref(null) const dictationStageRef = ref(null) const speakingResult = ref(null) const speakingReviewDone = ref(false) const readingPassage = ref(null) const readingLoading = ref(false) const readingError = ref('') const readingBatchWordIds = ref([]) const gapAnswers = ref({}) const gapStatus = ref({}) const readingChecked = ref(false) const readingAllCorrect = ref(false) const readingMcqAnswers = ref({}) const readingMcqChecked = ref(false) const readingMcqAllCorrect = ref(false) const currentWord = computed(() => currentMode.value === 'reading' ? null : (queue.value[queueIndex.value] ?? null) ) const cardLabel = computed(() => { if (!queue.value.length) return '0 / 0' if (currentMode.value === 'reading' && readingBatchWordIds.value.length) { const end = Math.min(queueIndex.value + readingBatchWordIds.value.length, queue.value.length) return `${queueIndex.value + 1}–${end} / ${queue.value.length}` } return `${queueIndex.value + 1} / ${queue.value.length}` }) const progressPct = computed(() => queue.value.length ? Math.round((doneCount.value / queue.value.length) * 100) : 0 ) const showFooter = computed( () => !loading.value && !completed.value && queue.value.length && currentMode.value !== 'reading' ) function setMode(id) { if (id === currentMode.value) return router.replace({ query: { ...route.query, mode: id } }) } async function reportSessionIfNeeded() { if (sessionReported.value || doneCount.value < 1) return sessionReported.value = true const duration_seconds = Math.max( 1, Math.round((Date.now() - sessionStartedAt.value) / 1000) ) try { const res = await completeVocabSession({ topic_id: topicId.value, duration_seconds, words_reviewed: doneCount.value, }) sessionXpEarned.value = res.xp_earned if (auth.profile) auth.profile.xp = res.total_xp useBadgeCelebrationStore().enqueue(res?.new_badges) await auth.fetchProfile() } catch { sessionReported.value = false } } async function goBack() { await reportSessionIfNeeded() router.push('/vocabulary') } async function loadQueue() { loading.value = true loadError.value = '' try { const data = await getStudyQueue(topicId.value) topicName.value = data.topic?.name || '' dueCount.value = data.due_count queue.value = data.words || [] restartSession() } catch (e) { loadError.value = e?.response?.data?.detail || 'Không tải được hàng đợi.' } finally { loading.value = false } } function restartSession() { queueIndex.value = 0 doneCount.value = 0 correctCount.value = 0 completed.value = false sessionStartedAt.value = Date.now() sessionReported.value = false sessionXpEarned.value = null resetCardState() onModeChange() } function resetCardState() { cardFlipped.value = false typingInput.value = '' typingResult.value = null speakingResult.value = null speakingReviewDone.value = false readingChecked.value = false readingAllCorrect.value = false readingMcqAnswers.value = {} readingMcqChecked.value = false readingMcqAllCorrect.value = false gapAnswers.value = {} gapStatus.value = {} } function onModeChange() { resetCardState() if (currentMode.value === 'reading') { loadReadingPassage() return } const w = currentWord.value if (!w) return nextTick(() => { if (currentMode.value === 'dictation') { dictationStageRef.value?.focus() speakEnglish(w.example || w.word) } else if (currentMode.value === 'typing') { typingStageRef.value?.focus() speakEnglish(w.word) } else if (currentMode.value === 'flashcard') { speakEnglish(w.word) } else if (currentMode.value === 'speaking') { speakEnglish(w.word) } }) } function readingBatchWords() { return queue.value.slice(queueIndex.value, queueIndex.value + READING_BATCH) } async function loadReadingPassage() { const batch = readingBatchWords() if (!batch.length) return readingLoading.value = true readingError.value = '' readingPassage.value = null try { const ids = batch.map((w) => w.id) const data = await generateReadingPassage(topicId.value, ids) readingPassage.value = data readingBatchWordIds.value = data.word_ids?.length ? data.word_ids : ids const ans = {} for (const p of data.paragraphs || []) { for (const part of p.parts || []) { if (part.type === 'gap' && part.id) ans[part.id] = '' } } gapAnswers.value = ans gapStatus.value = {} readingChecked.value = false readingMcqAnswers.value = {} readingMcqChecked.value = false readingMcqAllCorrect.value = false } catch (e) { readingError.value = e?.response?.data?.detail || 'Không tạo được đoạn văn.' } finally { readingLoading.value = false } } async function checkReadingPassage() { const answers = readingPassage.value?.answers || {} let allOk = true const status = {} for (const [gid, expected] of Object.entries(answers)) { const ok = normalizeVocabAnswer(gapAnswers.value[gid]) === normalizeVocabAnswer(expected) status[gid] = ok ? 'ok' : 'bad' if (!ok) allOk = false } gapStatus.value = status readingChecked.value = true readingAllCorrect.value = allOk } function onMcqSelect({ questionId, optionId }) { if (readingMcqChecked.value) return readingMcqAnswers.value = { ...readingMcqAnswers.value, [questionId]: optionId } } function checkReadingMcq() { const qs = readingPassage.value?.comprehension_questions || [] let allOk = true for (const q of qs) { if (readingMcqAnswers.value[q.id] !== q.correct_id) allOk = false } readingMcqChecked.value = true readingMcqAllCorrect.value = allOk } async function finishReadingBatch() { const qs = readingPassage.value?.comprehension_questions || [] if (qs.length && !readingMcqChecked.value) { checkReadingMcq() return } const answers = readingPassage.value?.answers || {} const gapKeys = Object.keys(answers) const idByGap = {} readingBatchWordIds.value.forEach((wid, i) => { idByGap[gapKeys[i] || `g${i}`] = wid }) for (const [gid, expected] of Object.entries(answers)) { const wid = idByGap[gid] if (!wid) continue const ok = normalizeVocabAnswer(gapAnswers.value[gid]) === normalizeVocabAnswer(expected) await submitReviewById(wid, ok) doneCount.value++ if (ok) correctCount.value++ } const step = readingBatchWordIds.value.length || READING_BATCH const next = queueIndex.value + step if (next >= queue.value.length) { completed.value = true return } queueIndex.value = next resetCardState() loadReadingPassage() } async function submitReviewById(wordId, correct) { reviewing.value = true try { await recordReview(topicId.value, wordId, correct ? 5 : 2) } catch (e) { console.error(e) } finally { reviewing.value = false } } async function submitReview(correct) { const w = currentWord.value if (!w) return await submitReviewById(w.id, correct) } function speakWord(word) { speakEnglish(word) } function speakExample() { const w = currentWord.value speakEnglish(w?.example || w?.word) } async function markAnswer(correct) { correctCount.value += correct ? 1 : 0 doneCount.value++ await submitReview(correct) advanceQueue() } async function checkTypingWord() { const w = currentWord.value if (!w || typingResult.value) return const ok = normalizeVocabAnswer(typingInput.value) === normalizeVocabAnswer(w.word) typingResult.value = ok ? 'correct' : 'wrong' if (ok) correctCount.value++ doneCount.value++ await submitReview(ok) } async function checkDictation() { const w = currentWord.value if (!w || typingResult.value) return const ok = normalizeVocabAnswer(typingInput.value) === normalizeVocabAnswer(w.word) typingResult.value = ok ? 'correct' : 'wrong' if (ok) correctCount.value++ doneCount.value++ await submitReview(ok) } async function onSpeakingScored({ correct }) { speakingResult.value = correct ? 'correct' : 'wrong' if (speakingReviewDone.value) return speakingReviewDone.value = true if (correct) correctCount.value++ doneCount.value++ await submitReview(correct) } function onSpeakingRetry() { speakingResult.value = null } function nextWord() { advanceQueue() } async function advanceQueue() { const next = queueIndex.value + 1 if (next >= queue.value.length) { completed.value = true await reportSessionIfNeeded() return } queueIndex.value = next resetCardState() onModeChange() } function goPrevCard() { if (queueIndex.value <= 0) return queueIndex.value-- resetCardState() onModeChange() } function goNextCard() { if (queueIndex.value >= queue.value.length - 1) return queueIndex.value++ resetCardState() onModeChange() } function onKeydown(e) { if (loading.value || completed.value) return if (currentMode.value !== 'flashcard' || !currentWord.value) return if (e.code === 'Space' || e.code === 'Enter') { e.preventDefault() if (!cardFlipped.value) cardFlipped.value = true } if (e.code === 'ArrowLeft') { e.preventDefault() goPrevCard() } if (e.code === 'ArrowRight') { e.preventDefault() goNextCard() } } onMounted(() => { loadQueue() window.addEventListener('keydown', onKeydown) }) onUnmounted(() => { window.removeEventListener('keydown', onKeydown) reportSessionIfNeeded() }) watch(() => route.params.topicId, loadQueue) watch(currentMode, onModeChange) watch(queueIndex, () => { if (currentMode.value === 'reading') loadReadingPassage() }) return { currentMode, loading, loadError, topicName, dueCount, queue, queueIndex, doneCount, correctCount, completed, reviewing, cardFlipped, typingInput, typingResult, speakingResult, typingStageRef, dictationStageRef, readingPassage, readingLoading, readingError, readingBatchWordIds, gapAnswers, gapStatus, readingChecked, readingAllCorrect, readingMcqAnswers, readingMcqChecked, readingMcqAllCorrect, currentWord, cardLabel, progressPct, showFooter, sessionXpEarned, setMode, goBack, loadQueue, restartSession, loadReadingPassage, checkReadingPassage, onMcqSelect, checkReadingMcq, finishReadingBatch, speakWord, speakExample, markAnswer, checkTypingWord, checkDictation, onSpeakingScored, onSpeakingRetry, nextWord, goPrevCard, goNextCard, } }