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

992 lines
26 KiB
Vue

<template>
<div class="vocabulary-hub">
<!-- 頁面標題 -->
<div class="page-header">
<div class="header-content">
<div class="title-section">
<h1 class="page-title">詞彙學習中心</h1>
<p class="page-subtitle">透過多種練習模式提升你的詞彙量</p>
</div>
<!-- 書籤和工具按鈕 -->
<div class="header-actions">
<q-btn
:icon="isBookmarked ? 'bookmark' : 'bookmark_border'"
:color="isBookmarked ? 'amber' : 'grey-6'"
round
flat
size="md"
@click="toggleBookmarkStatus"
:title="isBookmarked ? '移除書籤 (Ctrl+D)' : '加入書籤 (Ctrl+D)'"
>
<q-tooltip>{{ isBookmarked ? '移除書籤' : '加入書籤' }} (Ctrl+D)</q-tooltip>
</q-btn>
<q-btn
icon="more_vert"
round
flat
size="md"
color="grey-6"
>
<q-menu>
<q-list style="min-width: 200px">
<q-item clickable @click="openBookmarkManager">
<q-item-section avatar>
<q-icon name="bookmarks" />
</q-item-section>
<q-item-section>管理書籤</q-item-section>
</q-item>
<q-item clickable @click="exportBookmarksToFile">
<q-item-section avatar>
<q-icon name="download" />
</q-item-section>
<q-item-section>匯出書籤</q-item-section>
</q-item>
<q-separator />
<q-item clickable @click="showShortcuts = true">
<q-item-section avatar>
<q-icon name="keyboard" />
</q-item-section>
<q-item-section>快捷鍵說明</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-btn>
</div>
</div>
</div>
<!-- 學習統計概覽 -->
<div class="stats-overview">
<q-card flat class="stat-card">
<q-card-section>
<div class="stat-content">
<q-icon name="book" size="xl" color="primary" />
<div class="stat-info">
<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-content">
<q-icon name="trending_up" size="xl" color="green" />
<div class="stat-info">
<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-content">
<q-icon name="schedule" size="xl" color="orange" />
<div class="stat-info">
<div class="stat-value">{{ vocabularyStore.wordsForReview.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-content">
<q-icon name="school" size="xl" color="blue" />
<div class="stat-info">
<div class="stat-value">{{ vocabularyStore.learningWords.length }}</div>
<div class="stat-label">學習中</div>
</div>
</div>
</q-card-section>
</q-card>
</div>
<!-- 快速開始練習 -->
<div class="quick-start-section">
<h2 class="section-title">快速開始</h2>
<div class="practice-modes">
<q-card class="practice-card" clickable @click="startPractice('multiple_choice_definition')">
<q-card-section>
<div class="practice-icon">
<q-icon name="quiz" size="3rem" color="primary" />
</div>
<div class="practice-info">
<h3>選擇題練習</h3>
<p>測試詞彙定義理解</p>
<div class="practice-meta">
<q-chip size="sm" color="primary" text-color="white">10</q-chip>
<q-chip size="sm" outline>基礎-中級</q-chip>
</div>
</div>
</q-card-section>
</q-card>
<q-card class="practice-card" clickable @click="startPractice('multiple_choice_translation')">
<q-card-section>
<div class="practice-icon">
<q-icon name="translate" size="3rem" color="secondary" />
</div>
<div class="practice-info">
<h3>翻譯練習</h3>
<p>英中翻譯能力測試</p>
<div class="practice-meta">
<q-chip size="sm" color="secondary" text-color="white">10</q-chip>
<q-chip size="sm" outline>中級-高級</q-chip>
</div>
</div>
</q-card-section>
</q-card>
<q-card class="practice-card" clickable @click="startPractice('multiple_choice_synonym')">
<q-card-section>
<div class="practice-icon">
<q-icon name="compare_arrows" size="3rem" color="accent" />
</div>
<div class="practice-info">
<h3>同義詞練習</h3>
<p>詞彙關聯性訓練</p>
<div class="practice-meta">
<q-chip size="sm" color="accent" text-color="white">10</q-chip>
<q-chip size="sm" outline>高級</q-chip>
</div>
</div>
</q-card-section>
</q-card>
</div>
<!-- 自定義練習按鈕 -->
<div class="custom-practice">
<q-btn
size="lg"
color="primary"
icon="settings"
label="自定義練習設定"
@click="goToCustomPractice"
outline
/>
</div>
</div>
<!-- 學習進度 -->
<div class="progress-section" v-if="vocabularyStore.progress.length > 0">
<h2 class="section-title">學習進度</h2>
<q-card flat class="progress-card">
<q-card-section>
<div class="progress-header">
<div class="progress-title">掌握度分佈</div>
<div class="progress-info">
{{ Math.round(overallProgress) }}% 整體掌握度
</div>
</div>
<div class="progress-bars">
<div class="progress-bar-item">
<div class="bar-label">初學者 (0-25%)</div>
<q-linear-progress
:value="masteryDistribution.beginner / vocabularyStore.progress.length"
color="red"
track-color="grey-3"
size="12px"
rounded
/>
<div class="bar-value">{{ masteryDistribution.beginner }} 詞</div>
</div>
<div class="progress-bar-item">
<div class="bar-label">學習中 (26-50%)</div>
<q-linear-progress
:value="masteryDistribution.intermediate / vocabularyStore.progress.length"
color="orange"
track-color="grey-3"
size="12px"
rounded
/>
<div class="bar-value">{{ masteryDistribution.intermediate }} 詞</div>
</div>
<div class="progress-bar-item">
<div class="bar-label">熟悉 (51-75%)</div>
<q-linear-progress
:value="masteryDistribution.advanced / vocabularyStore.progress.length"
color="blue"
track-color="grey-3"
size="12px"
rounded
/>
<div class="bar-value">{{ masteryDistribution.advanced }} 詞</div>
</div>
<div class="progress-bar-item">
<div class="bar-label">已掌握 (76-100%)</div>
<q-linear-progress
:value="masteryDistribution.mastered / vocabularyStore.progress.length"
color="green"
track-color="grey-3"
size="12px"
rounded
/>
<div class="bar-value">{{ masteryDistribution.mastered }} 詞</div>
</div>
</div>
</q-card-section>
</q-card>
</div>
<!-- 待複習詞彙 -->
<div class="review-section" v-if="vocabularyStore.wordsForReview.length > 0">
<div class="section-header">
<h2 class="section-title">今日複習</h2>
<q-btn
color="orange"
icon="refresh"
label="開始複習"
@click="startReview"
/>
</div>
<div class="review-words">
<q-card
v-for="progress in vocabularyStore.wordsForReview.slice(0, 6)"
:key="progress.vocabulary_id"
class="review-word-card"
flat
>
<q-card-section>
<div class="word-info">
<div class="word">{{ getVocabularyById(progress.vocabulary_id)?.word }}</div>
<div class="mastery">{{ progress.mastery_level }}% 掌握</div>
</div>
</q-card-section>
</q-card>
</div>
<div v-if="vocabularyStore.wordsForReview.length > 6" class="more-words">
還有 {{ vocabularyStore.wordsForReview.length - 6 }} 個詞彙待複習
</div>
</div>
<!-- 系統狀態 (開發用) -->
<div v-if="isDev" class="dev-info">
<q-card flat class="dev-card">
<q-card-section>
<div class="text-h6">系統狀態 (開發模式)</div>
<div class="dev-status">
<div>✅ Vue 3 + Composition API</div>
<div>✅ Quasar Framework</div>
<div>✅ Pinia 狀態管理</div>
<div>✅ 詞彙練習系統</div>
<div>✅ 認證狀態: {{ authStore.isAuthenticated ? '已登入' : '未登入' }}</div>
<div>✅ 路由: {{ $route.path }}</div>
</div>
</q-card-section>
</q-card>
</div>
<!-- 書籤管理對話框 -->
<q-dialog v-model="showBookmarkManager" persistent>
<q-card style="min-width: 600px; max-width: 800px">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">書籤管理</div>
<q-space />
<q-btn icon="close" flat round dense v-close-popup />
</q-card-section>
<q-card-section>
<div class="row q-gutter-sm q-mb-md">
<q-input
v-model="bookmarkSearch"
placeholder="搜尋書籤..."
outlined
dense
class="col"
>
<template v-slot:prepend>
<q-icon name="search" />
</template>
</q-input>
<q-btn
color="primary"
icon="upload"
label="匯入"
@click="importBookmarksDialog"
/>
</div>
<q-list separator v-if="filteredBookmarks.length > 0">
<q-item
v-for="bookmark in filteredBookmarks"
:key="bookmark.id"
clickable
@click="navigateToBookmark(bookmark)"
>
<q-item-section avatar>
<q-icon name="bookmark" color="amber" />
</q-item-section>
<q-item-section>
<q-item-label>{{ bookmark.title }}</q-item-label>
<q-item-label caption>{{ bookmark.description }}</q-item-label>
<q-item-label caption class="text-grey">
{{ formatDate(bookmark.createdAt) }}
</q-item-label>
</q-item-section>
<q-item-section side>
<q-btn
icon="delete"
flat
round
size="sm"
color="negative"
@click.stop="removeBookmarkById(bookmark.id)"
/>
</q-item-section>
</q-item>
</q-list>
<div v-else class="text-center q-py-xl text-grey-5">
<q-icon name="bookmark_border" size="4rem" />
<div class="q-mt-md">尚無書籤</div>
</div>
</q-card-section>
</q-card>
</q-dialog>
<!-- 快捷鍵說明對話框 -->
<q-dialog v-model="showShortcuts">
<q-card style="min-width: 500px">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">快捷鍵說明</div>
<q-space />
<q-btn icon="close" flat round dense v-close-popup />
</q-card-section>
<q-card-section>
<div class="shortcut-categories">
<div class="shortcut-category">
<div class="category-title">導航</div>
<div class="shortcut-item">
<kbd>Ctrl + H</kbd>
<span>返回學習首頁</span>
</div>
<div class="shortcut-item">
<kbd>Ctrl + V</kbd>
<span>打開詞彙學習</span>
</div>
<div class="shortcut-item">
<kbd>Ctrl + R</kbd>
<span>打開智能複習</span>
</div>
</div>
<div class="shortcut-category">
<div class="category-title">學習工具</div>
<div class="shortcut-item">
<kbd>Ctrl + D</kbd>
<span>切換書籤</span>
</div>
<div class="shortcut-item">
<kbd>Ctrl + F</kbd>
<span>搜尋</span>
</div>
<div class="shortcut-item">
<kbd>F1</kbd>
<span>開啟字典</span>
</div>
</div>
<div class="shortcut-category">
<div class="category-title">其他</div>
<div class="shortcut-item">
<kbd>Shift + ?</kbd>
<span>顯示此說明</span>
</div>
</div>
</div>
</q-card-section>
</q-card>
</q-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useVocabularyStore } from '@/stores/vocabulary'
import { useBrowserBookmarks } from '@/composables/useBrowserBookmarks'
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
import { useQuasar } from 'quasar'
import type { ExerciseType } from '@/types/vocabulary'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const vocabularyStore = useVocabularyStore()
const $q = useQuasar()
// 書籤功能
const {
bookmarks,
isBookmarked,
toggleBookmark,
checkBookmarkStatus,
removeBookmark,
searchBookmarks,
exportBookmarks,
importBookmarks
} = useBrowserBookmarks()
// 快捷鍵
const { registerShortcut } = useKeyboardShortcuts()
const isDev = ref(import.meta.env.DEV)
// 對話框狀態
const showBookmarkManager = ref(false)
const showShortcuts = ref(false)
const bookmarkSearch = ref('')
// 計算屬性
const masteryDistribution = computed(() => {
const progress = vocabularyStore.progress
return {
beginner: progress.filter(p => p.mastery_level <= 25).length,
intermediate: progress.filter(p => p.mastery_level > 25 && p.mastery_level <= 50).length,
advanced: progress.filter(p => p.mastery_level > 50 && p.mastery_level <= 75).length,
mastered: progress.filter(p => p.mastery_level > 75).length
}
})
const overallProgress = computed(() => {
const progress = vocabularyStore.progress
if (progress.length === 0) return 0
const totalMastery = progress.reduce((sum, p) => sum + p.mastery_level, 0)
return totalMastery / progress.length
})
// 書籤相關計算屬性
const filteredBookmarks = computed(() => {
if (!bookmarkSearch.value) {
return bookmarks.value
}
return searchBookmarks(bookmarkSearch.value)
})
// 方法
const startPractice = async (exerciseType: ExerciseType) => {
try {
// 設定快速練習參數
vocabularyStore.updatePracticeSettings({
exercise_type: exerciseType,
difficulty_levels: [1, 2, 3],
question_count: 10,
enable_audio: true,
enable_hints: true,
shuffle_options: true
})
// 跳轉到練習頁面
router.push('/learning/vocabulary/practice')
} catch (error) {
console.error('開始練習失敗:', error)
}
}
const goToCustomPractice = () => {
router.push('/learning/vocabulary/practice')
}
const startReview = () => {
// 開始複習模式
router.push('/learning/vocabulary/review')
}
const getVocabularyById = (id: string) => {
return vocabularyStore.vocabularies.find(v => v.id === id)
}
// 書籤相關方法
const toggleBookmarkStatus = () => {
const currentUrl = `${window.location.origin}${route.fullPath}`
const result = toggleBookmark({
title: '詞彙學習中心 - Drama Ling',
url: currentUrl,
description: '透過多種練習模式提升你的詞彙量'
})
$q.notify({
message: result.bookmarked ? '已加入書籤' : '已移除書籤',
icon: result.bookmarked ? 'bookmark' : 'bookmark_border',
color: result.bookmarked ? 'positive' : 'info',
position: 'top'
})
}
const openBookmarkManager = () => {
showBookmarkManager.value = true
}
const navigateToBookmark = (bookmark: any) => {
if (bookmark.url.startsWith(window.location.origin)) {
const path = bookmark.url.replace(window.location.origin, '')
router.push(path)
} else {
window.open(bookmark.url, '_blank')
}
showBookmarkManager.value = false
}
const removeBookmarkById = (id: string) => {
$q.dialog({
title: '確認刪除',
message: '確定要移除此書籤嗎?',
cancel: true,
persistent: true
}).onOk(() => {
if (removeBookmark(id)) {
$q.notify({
message: '書籤已移除',
color: 'positive',
icon: 'check'
})
}
})
}
const exportBookmarksToFile = () => {
exportBookmarks()
$q.notify({
message: '書籤已匯出',
color: 'positive',
icon: 'download'
})
}
const importBookmarksDialog = () => {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.json'
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (file) {
try {
const importedCount = await importBookmarks(file)
$q.notify({
message: `已匯入 ${importedCount} 個書籤`,
color: 'positive',
icon: 'upload'
})
} catch (error) {
$q.notify({
message: '匯入失敗:' + (error as Error).message,
color: 'negative',
icon: 'error'
})
}
}
}
input.click()
}
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat('zh-TW', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
}).format(date)
}
// 生命週期
onMounted(async () => {
// 載入詞彙數據
await vocabularyStore.fetchVocabularies()
// 檢查當前頁面書籤狀態
checkBookmarkStatus(`${window.location.origin}${route.fullPath}`)
// 註冊書籤快捷鍵
registerShortcut({
key: 'd',
ctrl: true,
action: toggleBookmarkStatus,
description: '切換書籤'
})
console.log('詞彙學習中心已載入')
console.log('詞彙數量:', vocabularyStore.vocabularies.length)
console.log('學習進度:', vocabularyStore.progress.length)
})
</script>
<style lang="scss" scoped>
.vocabulary-hub {
padding: $space-6;
max-width: 1200px;
margin: 0 auto;
}
.page-header {
margin-bottom: $space-8;
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
gap: $space-4;
@media (max-width: 768px) {
flex-direction: column;
text-align: center;
}
}
.title-section {
text-align: center;
flex: 1;
.page-title {
font-size: $text-4xl;
font-weight: 800;
color: $text-primary;
margin-bottom: $space-2;
}
.page-subtitle {
font-size: $text-xl;
color: $text-secondary;
}
}
.header-actions {
display: flex;
gap: $space-2;
}
}
.stats-overview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: $space-4;
margin-bottom: $space-8;
.stat-card {
background: $card-background;
border-radius: $radius-lg;
transition: all 0.3s ease;
&:hover {
transform: translateY(-2px);
box-shadow: $shadow-lg;
}
.stat-content {
display: flex;
align-items: center;
gap: $space-4;
.stat-info {
.stat-value {
font-size: $text-3xl;
font-weight: 700;
color: $text-primary;
}
.stat-label {
font-size: $text-base;
color: $text-secondary;
}
}
}
}
}
.quick-start-section {
margin-bottom: $space-8;
.section-title {
font-size: $text-2xl;
font-weight: 700;
color: $text-primary;
margin-bottom: $space-6;
}
.practice-modes {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: $space-6;
margin-bottom: $space-6;
.practice-card {
background: $card-background;
border-radius: $radius-lg;
transition: all 0.3s ease;
cursor: pointer;
&:hover {
transform: translateY(-4px);
box-shadow: $shadow-xl;
}
.practice-icon {
text-align: center;
margin-bottom: $space-4;
}
.practice-info {
text-align: center;
h3 {
font-size: $text-lg;
font-weight: 600;
color: $text-primary;
margin-bottom: $space-2;
}
p {
font-size: $text-base;
color: $text-secondary;
margin-bottom: $space-4;
}
.practice-meta {
display: flex;
justify-content: center;
gap: $space-2;
}
}
}
}
.custom-practice {
text-align: center;
}
}
.progress-section {
margin-bottom: $space-8;
.section-title {
font-size: $text-2xl;
font-weight: 700;
color: $text-primary;
margin-bottom: $space-6;
}
.progress-card {
background: $card-background;
border-radius: $radius-lg;
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $space-6;
.progress-title {
font-size: $text-lg;
font-weight: 600;
color: $text-primary;
}
.progress-info {
font-size: $text-base;
color: $text-secondary;
}
}
.progress-bars {
.progress-bar-item {
display: flex;
align-items: center;
gap: $space-4;
margin-bottom: $space-4;
.bar-label {
min-width: 120px;
font-size: $text-sm;
color: $text-secondary;
}
.q-linear-progress {
flex: 1;
}
.bar-value {
min-width: 60px;
text-align: right;
font-size: $text-sm;
color: $text-primary;
font-weight: 600;
}
}
}
}
}
.review-section {
margin-bottom: $space-8;
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $space-6;
.section-title {
font-size: $text-2xl;
font-weight: 700;
color: $text-primary;
}
}
.review-words {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: $space-4;
margin-bottom: $space-4;
.review-word-card {
background: rgba($warning-orange, 0.1);
border: 1px solid rgba($warning-orange, 0.3);
border-radius: $radius-md;
.word-info {
display: flex;
justify-content: space-between;
align-items: center;
.word {
font-size: $text-lg;
font-weight: 600;
color: $text-primary;
}
.mastery {
font-size: $text-sm;
color: $warning-orange;
font-weight: 500;
}
}
}
}
.more-words {
text-align: center;
color: $text-secondary;
font-size: $text-base;
}
}
.dev-info {
margin-top: $space-8;
.dev-card {
background: rgba($info-cyan, 0.1);
border: 1px solid rgba($info-cyan, 0.3);
border-radius: $radius-lg;
.dev-status {
margin-top: $space-4;
div {
margin-bottom: $space-1;
font-size: $text-sm;
color: $text-secondary;
}
}
}
}
@media (max-width: 768px) {
.vocabulary-hub {
padding: $space-4;
}
.stats-overview {
grid-template-columns: repeat(2, 1fr);
gap: $space-3;
}
.practice-modes {
grid-template-columns: 1fr;
gap: $space-4;
}
.review-words {
grid-template-columns: 1fr;
}
.section-header {
flex-direction: column;
gap: $space-3;
align-items: stretch;
}
}
// 快捷鍵說明樣式
.shortcut-categories {
.shortcut-category {
margin-bottom: $space-4;
&:last-child {
margin-bottom: 0;
}
.category-title {
font-size: $text-lg;
font-weight: 600;
color: $text-primary;
margin-bottom: $space-3;
border-bottom: 2px solid $divider;
padding-bottom: $space-1;
}
.shortcut-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: $space-2 0;
border-bottom: 1px solid rgba($divider, 0.5);
&:last-child {
border-bottom: none;
}
kbd {
background: rgba($text-secondary, 0.1);
border: 1px solid rgba($text-secondary, 0.3);
border-radius: $radius-sm;
padding: $space-1 $space-2;
font-family: 'Courier New', monospace;
font-size: $text-xs;
color: $text-primary;
min-width: 60px;
text-align: center;
}
span {
color: $text-secondary;
font-size: $text-sm;
}
}
}
}
</style>