dramaling-app/apps/web/src/views/learning/VocabularyPracticeView.vue

829 lines
22 KiB
Vue

<template>
<div class="vocabulary-practice">
<!-- 練習設定面板 -->
<div v-if="!currentSession" class="practice-setup">
<div class="setup-header">
<h1 class="page-title">詞彙練習</h1>
<p class="page-subtitle">選擇練習類型和難度開始學習</p>
<!-- 多標籤頁狀態指示器 -->
<div v-if="activeTabs.size > 1" class="multi-tab-status">
<q-chip
color="info"
text-color="white"
icon="tab"
:label="`${activeTabs.size} 個學習標籤頁`"
/>
<q-tooltip>偵測到多個學習標籤頁,進度將自動同步</q-tooltip>
</div>
</div>
<q-card class="setup-card">
<q-card-section>
<div class="setup-section">
<h3 class="section-title">練習類型</h3>
<q-option-group
v-model="selectedExerciseType"
:options="exerciseTypeOptions"
color="primary"
inline
/>
</div>
<q-separator class="q-my-md" />
<div class="setup-section">
<h3 class="section-title">難度等級</h3>
<q-option-group
v-model="selectedDifficulty"
:options="difficultyOptions"
color="primary"
type="checkbox"
inline
/>
</div>
<q-separator class="q-my-md" />
<div class="setup-section">
<h3 class="section-title">練習設定</h3>
<div class="settings-grid">
<q-input
v-model.number="questionCount"
label="題目數量"
type="number"
:min="5"
:max="50"
outlined
dense
/>
<q-toggle
v-model="enableAudio"
label="啟用音頻"
color="primary"
/>
<q-toggle
v-model="enableHints"
label="啟用提示"
color="primary"
/>
<q-toggle
v-model="shuffleOptions"
label="選項隨機排序"
color="primary"
/>
</div>
</div>
</q-card-section>
<q-card-actions align="right" class="q-pa-md">
<q-btn
color="primary"
size="lg"
@click="startPractice"
:loading="vocabularyStore.isLoading"
:disable="selectedDifficulty.length === 0"
>
<q-icon name="play_arrow" class="q-mr-sm" />
開始練習
</q-btn>
</q-card-actions>
</q-card>
<!-- 統計資訊 -->
<div class="stats-section">
<q-card flat class="stat-card">
<q-card-section>
<div class="stat-item">
<q-icon name="book" size="md" color="primary" />
<div>
<div class="stat-value">{{ vocabularyStore.vocabularies.length }}</div>
<div class="stat-label">總詞彙數</div>
</div>
</div>
</q-card-section>
</q-card>
<q-card flat class="stat-card">
<q-card-section>
<div class="stat-item">
<q-icon name="trending_up" size="md" color="green" />
<div>
<div class="stat-value">{{ vocabularyStore.masteredWords.length }}</div>
<div class="stat-label">已掌握</div>
</div>
</div>
</q-card-section>
</q-card>
<q-card flat class="stat-card">
<q-card-section>
<div class="stat-item">
<q-icon name="schedule" size="md" color="orange" />
<div>
<div class="stat-value">{{ vocabularyStore.wordsForReview.length }}</div>
<div class="stat-label">待複習</div>
</div>
</div>
</q-card-section>
</q-card>
</div>
</div>
<!-- 練習進行中 -->
<div v-else class="practice-session">
<!-- 進度條 -->
<div class="session-header">
<div class="progress-info">
<div class="progress-text">
{{ currentSession.completed_questions }} / {{ currentSession.total_questions }}
</div>
<div class="accuracy-text">
準確率: {{ Math.round(vocabularyStore.sessionAccuracy) }}%
</div>
</div>
<q-linear-progress
:value="vocabularyStore.sessionProgress / 100"
color="primary"
size="8px"
rounded
/>
</div>
<!-- 當前題目 -->
<div v-if="currentExercise" class="exercise-container">
<q-card class="exercise-card">
<q-card-section>
<!-- 題目 -->
<div class="question-section">
<h2 class="question-text">{{ currentExercise.question }}</h2>
<!-- 詞彙資訊 -->
<div v-if="currentVocabulary" class="vocabulary-info">
<div class="word-display">
<span class="word">{{ currentVocabulary.word }}</span>
<span class="phonetic">{{ currentVocabulary.phonetic }}</span>
<q-btn
v-if="enableAudio && currentVocabulary.audio_url"
flat
round
dense
icon="volume_up"
@click="playAudio"
class="audio-btn"
/>
</div>
</div>
</div>
<!-- 選項 -->
<div class="options-section">
<q-option-group
v-model="selectedAnswer"
:options="displayOptions"
color="primary"
type="radio"
@update:model-value="onAnswerSelect"
/>
</div>
<!-- 提示 -->
<div v-if="showHint && enableHints" class="hint-section">
<q-banner class="hint-banner" icon="lightbulb">
<template v-slot:action>
<q-btn flat round dense icon="close" @click="showHint = false" />
</template>
{{ currentExercise.explanation || '這是一個提示...' }}
</q-banner>
</div>
</q-card-section>
<q-card-actions align="between" class="q-pa-md">
<div>
<q-btn
v-if="enableHints && !showHint"
flat
icon="help"
label="提示"
@click="showHint = true"
/>
</div>
<div class="action-buttons">
<q-btn
flat
label="跳過"
@click="skipQuestion"
class="q-mr-md"
/>
<q-btn
color="primary"
label="確認"
@click="submitAnswer"
:disable="!selectedAnswer"
:loading="isSubmitting"
/>
</div>
</q-card-actions>
</q-card>
<!-- 即時反饋 -->
<div v-if="showFeedback" class="feedback-section">
<q-card :class="['feedback-card', lastAnswerCorrect ? 'correct' : 'incorrect']">
<q-card-section>
<div class="feedback-content">
<q-icon
:name="lastAnswerCorrect ? 'check_circle' : 'cancel'"
size="xl"
:color="lastAnswerCorrect ? 'green' : 'red'"
/>
<div>
<div class="feedback-title">
{{ lastAnswerCorrect ? '答對了!' : '答錯了' }}
</div>
<div v-if="!lastAnswerCorrect && correctAnswerText" class="correct-answer">
正確答案:{{ correctAnswerText }}
</div>
</div>
</div>
</q-card-section>
</q-card>
</div>
</div>
<!-- 練習完成 -->
<div v-if="sessionCompleted" class="session-complete">
<q-card class="completion-card">
<q-card-section class="text-center">
<q-icon name="celebration" size="4rem" color="primary" />
<h2>練習完成!</h2>
<div class="completion-stats">
<div class="stat-row">
<span>總題數:</span>
<span>{{ currentSession.total_questions }}</span>
</div>
<div class="stat-row">
<span>答對:</span>
<span class="correct">{{ currentSession.correct_answers }}</span>
</div>
<div class="stat-row">
<span>答錯:</span>
<span class="incorrect">{{ currentSession.incorrect_answers }}</span>
</div>
<div class="stat-row">
<span>準確率:</span>
<span>{{ Math.round(vocabularyStore.sessionAccuracy) }}%</span>
</div>
<div class="stat-row">
<span>平均時間:</span>
<span>{{ Math.round(currentSession.average_response_time / 1000) }}秒</span>
</div>
</div>
</q-card-section>
<q-card-actions align="center">
<q-btn
color="primary"
label="查看詳細結果"
@click="goToResults"
class="q-mr-md"
/>
<q-btn
outline
color="primary"
label="再次練習"
@click="restartPractice"
/>
</q-card-actions>
</q-card>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { useVocabularyStore } from '@/stores/vocabulary'
import { useMultiTabLearning } from '@/composables/useMultiTabLearning'
import { useQuasar } from 'quasar'
import type { ExerciseType } from '@/types/vocabulary'
const router = useRouter()
const vocabularyStore = useVocabularyStore()
const $q = useQuasar()
// 多標籤頁學習支援
const {
currentTabId,
activeTabs,
isSyncing,
syncConflicts,
startMultiTabSession,
resolveConflict
} = useMultiTabLearning()
// 響應式數據
const selectedExerciseType = ref<ExerciseType>('multiple_choice_definition')
const selectedDifficulty = ref<number[]>([1, 2, 3])
const questionCount = ref(10)
const enableAudio = ref(true)
const enableHints = ref(true)
const shuffleOptions = ref(true)
const selectedAnswer = ref<string | null>(null)
const showHint = ref(false)
const showFeedback = ref(false)
const lastAnswerCorrect = ref(false)
const correctAnswerText = ref('')
const isSubmitting = ref(false)
const sessionCompleted = ref(false)
const questionStartTime = ref<number>(0)
// 練習類型選項
const exerciseTypeOptions = [
{ label: '詞義選擇', value: 'multiple_choice_definition' },
{ label: '翻譯選擇', value: 'multiple_choice_translation' },
{ label: '同義詞選擇', value: 'multiple_choice_synonym' }
]
// 難度選項
const difficultyOptions = [
{ label: '基礎 (1)', value: 1 },
{ label: '初級 (2)', value: 2 },
{ label: '中級 (3)', value: 3 },
{ label: '高級 (4)', value: 4 },
{ label: '專家 (5)', value: 5 }
]
// 計算屬性
const currentSession = computed(() => vocabularyStore.currentSession)
const currentExercises = computed(() => vocabularyStore.currentExercises)
const currentExercise = computed(() => {
if (!currentSession.value || !currentExercises.value.length) return null
const index = currentSession.value.completed_questions
return currentExercises.value[index] || null
})
const currentVocabulary = computed(() => {
if (!currentExercise.value) return null
return vocabularyStore.vocabularies.find(v => v.id === currentExercise.value!.vocabulary_id)
})
const displayOptions = computed(() => {
if (!currentExercise.value) return []
let options = [...currentExercise.value.options]
if (shuffleOptions.value) {
// 簡單的洗牌算法
for (let i = options.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[options[i], options[j]] = [options[j], options[i]]
}
}
return options.map(opt => ({
label: opt.text,
value: opt.id
}))
})
// 方法
const startPractice = async () => {
try {
// 檢查是否有同步衝突
if (syncConflicts.value.length > 0) {
const result = await showConflictDialog()
if (!result) return
}
// 更新練習設定
vocabularyStore.updatePracticeSettings({
exercise_type: selectedExerciseType.value,
difficulty_levels: selectedDifficulty.value,
question_count: questionCount.value,
enable_audio: enableAudio.value,
enable_hints: enableHints.value,
shuffle_options: shuffleOptions.value
})
// 載入詞彙數據
await vocabularyStore.fetchVocabularies({
difficulty: selectedDifficulty.value,
limit: questionCount.value
})
// 開始多標籤頁會話
const vocabularyIds = vocabularyStore.vocabularies
.slice(0, questionCount.value)
.map(v => v.id)
await startMultiTabSession(vocabularyIds, selectedExerciseType.value)
// 重置狀態
resetQuestionState()
questionStartTime.value = Date.now()
// 通知用戶多標籤頁功能
if (activeTabs.value.size > 1) {
$q.notify({
message: `已偵測到 ${activeTabs.value.size} 個活躍學習標籤頁,學習進度將自動同步`,
icon: 'sync',
color: 'info',
position: 'top',
timeout: 3000
})
}
} catch (error) {
console.error('開始練習失敗:', error)
$q.notify({
message: '開始練習失敗,請重試',
color: 'negative',
icon: 'error'
})
}
}
const onAnswerSelect = (value: string) => {
// 記錄答案選擇時間
if (!questionStartTime.value) {
questionStartTime.value = Date.now()
}
}
const submitAnswer = async () => {
if (!currentExercise.value || !selectedAnswer.value) return
isSubmitting.value = true
const responseTime = Date.now() - questionStartTime.value
try {
await vocabularyStore.submitAnswer(
currentExercise.value.id,
selectedAnswer.value,
responseTime
)
// 顯示反饋
const option = currentExercise.value.options.find(opt => opt.id === selectedAnswer.value)
lastAnswerCorrect.value = option?.is_correct || false
if (!lastAnswerCorrect.value) {
const correctOption = currentExercise.value.options.find(opt => opt.is_correct)
correctAnswerText.value = correctOption?.text || ''
}
showFeedback.value = true
// 延遲後進入下一題
setTimeout(() => {
if (currentSession.value?.status === 'completed') {
sessionCompleted.value = true
} else {
nextQuestion()
}
}, 2000)
} catch (error) {
console.error('提交答案失敗:', error)
} finally {
isSubmitting.value = false
}
}
const skipQuestion = () => {
if (!currentSession.value) return
currentSession.value.completed_questions++
currentSession.value.skipped_questions++
if (currentSession.value.completed_questions >= currentSession.value.total_questions) {
sessionCompleted.value = true
vocabularyStore.completeSession()
} else {
nextQuestion()
}
}
const nextQuestion = () => {
resetQuestionState()
questionStartTime.value = Date.now()
}
const resetQuestionState = () => {
selectedAnswer.value = null
showHint.value = false
showFeedback.value = false
lastAnswerCorrect.value = false
correctAnswerText.value = ''
}
const playAudio = () => {
if (!currentVocabulary.value?.audio_url) return
const audio = new Audio(currentVocabulary.value.audio_url)
audio.play().catch(error => {
console.error('音頻播放失敗:', error)
})
}
const goToResults = () => {
router.push('/learning/vocabulary/results')
}
const restartPractice = () => {
vocabularyStore.resetCurrentSession()
sessionCompleted.value = false
}
// 多標籤頁衝突處理
const showConflictDialog = (): Promise<boolean> => {
return new Promise((resolve) => {
$q.dialog({
title: '多標籤頁學習偵測',
message: `已偵測到其他標籤頁正在進行相同類型的練習。請選擇處理方式:`,
options: {
type: 'radio',
model: 'merge',
items: [
{ label: '合併進度 (推薦)', value: 'merge' },
{ label: '覆蓋其他標籤頁', value: 'override' },
{ label: '取消此次練習', value: 'cancel' }
]
},
cancel: true,
persistent: true
}).onOk((data) => {
resolveConflict(data as 'merge' | 'override' | 'cancel')
resolve(data !== 'cancel')
}).onCancel(() => {
resolve(false)
})
})
}
// 生命週期
onMounted(async () => {
await vocabularyStore.fetchVocabularies()
})
// 監聽器
watch(currentSession, (newSession) => {
if (newSession?.status === 'completed') {
sessionCompleted.value = true
}
})
</script>
<style lang="scss" scoped>
.vocabulary-practice {
padding: $space-6;
max-width: 1000px;
margin: 0 auto;
}
.practice-setup {
.setup-header {
text-align: center;
margin-bottom: $space-8;
.page-title {
font-size: $text-3xl;
font-weight: 700;
color: $text-primary;
margin-bottom: $space-2;
}
.page-subtitle {
font-size: $text-lg;
color: $text-secondary;
}
}
.setup-card {
margin-bottom: $space-6;
.setup-section {
.section-title {
font-size: $text-lg;
font-weight: 600;
margin-bottom: $space-4;
color: $text-primary;
}
}
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: $space-4;
align-items: center;
}
}
}
.stats-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: $space-4;
.stat-card {
background: $card-background;
.stat-item {
display: flex;
align-items: center;
gap: $space-3;
.stat-value {
font-size: $text-2xl;
font-weight: 700;
color: $text-primary;
}
.stat-label {
font-size: $text-sm;
color: $text-secondary;
}
}
}
}
.practice-session {
.session-header {
margin-bottom: $space-6;
.progress-info {
display: flex;
justify-content: space-between;
margin-bottom: $space-2;
.progress-text {
font-size: $text-lg;
font-weight: 600;
color: $text-primary;
}
.accuracy-text {
font-size: $text-base;
color: $text-secondary;
}
}
}
}
.exercise-container {
.exercise-card {
margin-bottom: $space-4;
.question-section {
margin-bottom: $space-6;
.question-text {
font-size: $text-xl;
font-weight: 600;
color: $text-primary;
margin-bottom: $space-4;
}
.vocabulary-info {
.word-display {
display: flex;
align-items: center;
gap: $space-3;
padding: $space-4;
background: rgba($primary-teal, 0.1);
border-radius: $radius-lg;
.word {
font-size: $text-2xl;
font-weight: 700;
color: $primary-teal;
}
.phonetic {
font-size: $text-lg;
color: $text-secondary;
font-style: italic;
}
.audio-btn {
color: $primary-teal;
}
}
}
}
.options-section {
:deep(.q-radio) {
margin-bottom: $space-3;
padding: $space-3;
border-radius: $radius-md;
transition: background-color 0.2s;
&:hover {
background: rgba($primary-teal, 0.05);
}
}
}
.hint-section {
margin-top: $space-4;
.hint-banner {
background: rgba($warning-orange, 0.1);
color: $warning-orange;
}
}
.action-buttons {
display: flex;
gap: $space-2;
}
}
}
.feedback-section {
.feedback-card {
&.correct {
border-left: 4px solid $success-green;
background: rgba($success-green, 0.1);
}
&.incorrect {
border-left: 4px solid $error-red;
background: rgba($error-red, 0.1);
}
.feedback-content {
display: flex;
align-items: center;
gap: $space-3;
.feedback-title {
font-size: $text-lg;
font-weight: 600;
}
.correct-answer {
font-size: $text-base;
color: $text-secondary;
margin-top: $space-1;
}
}
}
}
.session-complete {
text-align: center;
.completion-card {
max-width: 500px;
margin: 0 auto;
.completion-stats {
margin: $space-6 0;
.stat-row {
display: flex;
justify-content: space-between;
padding: $space-2 0;
border-bottom: 1px solid $divider;
&:last-child {
border-bottom: none;
font-weight: 600;
font-size: $text-lg;
}
.correct {
color: $success-green;
font-weight: 600;
}
.incorrect {
color: $error-red;
font-weight: 600;
}
}
}
}
}
@media (max-width: 768px) {
.vocabulary-practice {
padding: $space-4;
}
.settings-grid {
grid-template-columns: 1fr;
}
.stats-section {
grid-template-columns: 1fr;
}
}
</style>