Compare commits

..

No commits in common. "644b6f2b152a22b8b6322942709b317d2c620368" and "917f45ec9159e035c9a8f3b15c5093e15d51d110" have entirely different histories.

296 changed files with 41779 additions and 66882 deletions

View File

@ -100,19 +100,7 @@
"Bash(timeout 10 curl -s http://localhost:3000/)",
"Bash(./sop/scripts/sop_consistency_check.sh:*)",
"Bash(timeout 30 npm run type-check:*)",
"Bash(timeout 10 curl -s -I http://localhost:3000/)",
"Bash(npm init:*)",
"Bash(timeout 5 curl -s -I http://localhost:3000/)",
"Bash(lsof:*)",
"Bash(npm run build:*)",
"Bash(npm run preview:*)",
"Bash(sort:*)",
"Bash(for:*)",
"Bash(grep:*)",
"Bash(sed:*)",
"Bash(do sed -i '' 's/u2190 u8fd4u56deu5c0eu822a/← 返回導航/g' \"$file\")",
"Bash(do)",
"Bash(tar:*)"
"Bash(timeout 10 curl -s -I http://localhost:3000/)"
],
"deny": [],
"ask": []

View File

@ -1,389 +0,0 @@
# 🚀 Drama Ling 企業級UI設計總體計劃
**建立日期**: 2025-01-15
**計劃版本**: v4.0 - 企業級重構
**架構基礎**: 共用模組架構 v3.0
**設計標準**: 企業級UI/UX規範
**執行目標**: 95+ UI畫面完整重設計
## 📋 計劃概述
### 🎯 核心目標
基於 Drama Ling v3.0 共用模組架構創建企業級標準的完整UI設計系統確保
- **100%符合功能規格**: 嚴格按照 `/docs/02_design/function-specs/` 規格執行
- **統一設計語言**: 完全遵循 `/docs/02_design/ui-ux/ui-ux-guidelines.md` 規範
- **企業級品質**: 達到Fortune 500企業內部系統標準
- **零設計債務**: 徹底重構,消除所有設計不一致問題
### 🏗️ 設計架構原則
1. **規格優先**: 所有設計必須100%符合功能規格文件
2. **模組化設計**: 基於v3.0共用模組架構的設計組件系統
3. **一致性保證**: 跨平台設計語言統一
4. **可擴展性**: 支援未來功能快速擴展的設計框架
5. **無障礙標準**: 符合WCAG 2.1 AA級無障礙要求
## 🔍 階段一:設計規範完善與標準化 (第1-2週)
### 1.1 UI/UX規範補全與更新 ⭐ **CRITICAL**
**目標**: 建立企業級完整設計規範系統
**工作內容**:
- [ ] **色彩系統完善**
- 引用文件: `/docs/02_design/ui-ux/ui-ux-guidelines.md` (第35-103行)
- 補全遺失的色彩定義:狀態色彩、反饋色彩、學習進度色彩
- 建立暗色/亮色主題完整色彩對照表
- 定義色彩使用場景和層次規範
- [ ] **字體系統標準化**
- 引用文件: `/docs/02_design/ui-ux/ui-ux-guidelines.md` (第105-136行)
- 補全遺失字體規格:多語言字體、特殊用途字體
- 建立響應式字體大小系統 (mobile/tablet/desktop)
- 定義字體層次和使用場景指南
- [ ] **間距與佈局系統**
- 引用文件: `/docs/02_design/ui-ux/ui-ux-guidelines.md` (第139-163行)
- 建立8px grid系統標準
- 定義響應式佈局斷點和規則
- 創建元件間距和頁面佈局標準模板
- [ ] **組件設計規範**
- 引用文件: `/docs/02_design/ui-ux/ui-ux-guidelines.md` (第188-200行)
- 補全缺失的組件規範:表單元件、遊戲化元件、學習專用元件
- 建立組件狀態系統 (default/hover/active/disabled/loading)
- 定義組件變體和使用場景
**輸出物**:
- `ui-ux-guidelines.md` 完整更新版本 (企業級標準)
- `design-system-components.md` 完整組件庫文檔
- `responsive-design-standards.md` 響應式設計標準
- `accessibility-guidelines.md` 無障礙設計指南
### 1.2 企業級設計系統建立
**目標**: 創建可重用的設計系統和組件庫
**工作內容**:
- [ ] **原子設計系統**: Atoms → Molecules → Organisms → Templates → Pages
- [ ] **Design Tokens**: 設計變數化管理系統
- [ ] **組件庫標準化**: 可重用UI組件集合
- [ ] **圖標系統**: 學習情境專用圖標設計
- [ ] **動畫設計語言**: 統一的動畫效果和互動反饋
**輸出物**:
- `design-system-tokens.css` - 完整設計變數系統
- `component-library.html` - 互動式組件展示
- `animation-guidelines.md` - 動畫設計標準
- `icon-system.svg` - 完整圖標庫
## 📱 階段二Mobile端企業級重設計 (第3-6週)
### 2.1 核心學習功能頁面群組 (第3-4週)
#### 2.1.1 情境對話系統 🎯 (優先級: P0)
**規格參考**:
- 主規格: `/docs/02_design/function-specs/mobile/01_situational-dialogue-mobile.md`
- 共用模組: `/docs/02_design/function-specs/common/ai-algorithm-specs.md`
- 共用模組: `/docs/02_design/function-specs/common/speaking-evaluation-specs.md`
- 共用模組: `/docs/02_design/function-specs/common/pragmatic-analysis-specs.md`
**需設計的頁面**:
- [ ] **UI_Dialogue_Practice_Main** - 情境對話練習主界面
- 設計要求: 語音輸入界面 (參考: ai-algorithm-specs.md 語音處理)
- 設計要求: 即時AI反饋顯示 (參考: ai-algorithm-specs.md AI評估系統)
- 設計要求: 劇情任務和詞彙任務雙重可視化
- 設計要求: 300秒限時挑戰計時器
- UI規範: 語音優先設計、即時語法反饋 (ui-ux-guidelines.md 第27-28行)
- [ ] **UI_Dialogue_Character_Selection** - 角色選擇頁面
- 設計要求: 角色卡片設計,突出角色特色和學習情境
- 設計要求: 角色能力和適合程度顯示
- [ ] **UI_Dialogue_Scene_Setting** - 場景設定頁面
- 設計要求: 沉浸式場景展示
- UI規範: 沉浸式學習環境設計 (ui-ux-guidelines.md 第9行)
- [ ] **UI_AI_Assistance_Panel** - AI輔助功能面板
- 設計要求: 回覆提示道具使用界面
- 設計要求: 語法即時檢測顯示
- UI規範: 智慧輔助、漸進引導 (ui-ux-guidelines.md 第21-22行)
- [ ] **UI_Dialogue_Results** - 對話練習結果頁面
- 設計要求: 口說評分五維雷達圖 (參考: speaking-evaluation-specs.md)
- 設計要求: 語用分析六維評估 (參考: pragmatic-analysis-specs.md)
- UI規範: 即時成就反饋 (ui-ux-guidelines.md 第25行)
#### 2.1.2 詞彙學習系統 📝 (優先級: P0)
**規格參考**:
- 主規格: `/docs/02_design/function-specs/mobile/02_vocabulary-learning-mobile.md`
- 共用模組: `/docs/02_design/function-specs/common/progressive-stage-system.md`
**需設計的頁面**:
- [ ] **UI_Vocab_Learning_Enhanced** - 多媒體詞彙學習主界面
- 設計要求: 詞彙展示 (音標、定義、例句)
- 設計要求: 雙語音頻系統 (標準速度/慢速)
- 設計要求: 智慧詞彙標註 (Source + Example)
- 設計要求: 視覺輔助學習 (例句配圖)
- UI規範: 詞彙學習流程 (ui-ux-guidelines.md 第29行)
- [ ] **UI_Vocab_Choice_Practice** - 詞彙選擇練習頁面
- 設計要求: 選擇題界面,支援多選和單選模式
- 設計要求: 即時正確性反饋
- [ ] **UI_Vocab_Fluency_Results** - 流暢度練習綜合結果
- 設計要求: 學習成效可視化展示
- 設計要求: 進度追蹤和建議系統
- [ ] **UI_Vocab_Review_System** - 間隔複習系統界面
- 設計要求: 複習提醒和排程界面
- UI規範: 間隔複習提醒 (ui-ux-guidelines.md 第31行)
#### 2.1.3 學習地圖系統 🗺️ (優先級: P0)
**規格參考**:
- 主規格: `/docs/02_design/function-specs/mobile/03_learning-map-mobile.md`
- 共用模組: `/docs/02_design/function-specs/common/progressive-stage-system.md`
**需設計的頁面**:
- [ ] **UI_Level_Map** - 學習地圖主畫面 (線性闖關版)
- 設計要求: 13階段×20劇本的地圖視覺化
- 設計要求: 四關進度指示器 (詞彙學習→詞彙熟悉→口說練習→情境對話)
- 設計要求: 關卡狀態管理 (🔒鎖定/⏳可進行/🔄進行中/✅已完成)
- 引用規格: progressive-stage-system.md 完整關卡系統
- [ ] **UI_Current_Level_Info** - 當前可進行關卡資訊面板
- 設計要求: 關卡詳細資訊展示
- 設計要求: 開始學習入口和準備指南
- [ ] **UI_Level_Progress_Detail** - 關卡進度詳情頁面
- 設計要求: 詳細進度追蹤和統計
- 設計要求: 個人表現分析
- [ ] **UI_Stage_Overview** - 階段總覽和劇本選擇
- 設計要求: 階段性學習目標展示
- 設計要求: 劇本選擇和預覽功能
- [ ] **UI_Level_Locked_Modal** - 關卡鎖定提示彈窗
- 設計要求: 解鎖條件清晰提示
- 設計要求: 引導用戶完成前置任務
### 2.2 商業功能頁面群組 (第4-5週)
#### 2.2.1 道具商店系統 🛒 (優先級: P1)
**規格參考**:
- 主規格: `/docs/02_design/function-specs/mobile/04_item-shop-mobile.md`
- 共用模組: `/docs/02_design/function-specs/common/business-rules.md`
**需設計的頁面**:
- [ ] **UI_Shop_Categories** - 道具商店分類主頁面
- 設計要求: 鑽石購買區 (5個價格套餐)
- 設計要求: 學習輔助道具區 (回覆提示、補命、加時)
- 設計要求: 限時挑戰道具區 (時間暫停、時間加成)
- 引用規格: business-rules.md 命條系統和經濟系統
- [ ] **UI_Diamond_Purchase** - 鑽石購買頁面
- 設計要求: 價格套餐展示和優惠信息
- 設計要求: 支付流程整合
- [ ] **UI_Item_Details** - 單一道具詳情頁面
- 設計要求: 道具功能詳細說明
- 設計要求: 使用場景和效果展示
- [ ] **UI_Shop_Item_Confirm** - 道具購買確認彈窗
- 設計要求: 購買資訊確認和風險提示
- UI規範: 高風險按鈕文字標注 (ui-ux-guidelines.md 第194行)
- [ ] **UI_Cost_Confirm_Popup** - 成本確認彈窗 (口說練習特別關卡)
- 設計要求: 特殊關卡成本說明
- 設計要求: 用戶決策支援資訊
#### 2.2.2 用戶認證系統 🔐 (優先級: P1)
**規格參考**:
- 主規格: `/docs/02_design/function-specs/mobile/05_user-authentication-mobile.md`
- 共用模組: `/docs/02_design/function-specs/common/business-rules.md`
**需設計的頁面**:
- [ ] **UI_Login_Main** - 主要登入頁面
- 設計要求: 多平台登入選項 (Google, Facebook, Apple)
- 設計要求: 記住登入和安全驗證
- 設計要求: 錯誤處理和安全提示
- [ ] **UI_SignUp_Main** - 用戶註冊頁面
- 設計要求: 分步驟註冊流程
- 設計要求: 即時驗證和錯誤提示
- 設計要求: 學習目標和程度設定
- [ ] **UI_PasswordReset_Form** - 密碼重置表單
- 設計要求: 多步驟驗證流程
- 設計要求: 安全性說明和引導
- [ ] **UI_PasswordReset_Popup** - 密碼重置確認彈窗
- 設計要求: 重置成功確認和後續指引
- [ ] **UI_Account_List** - 帳戶列表管理頁面
- 設計要求: 多帳戶管理和切換
- 設計要求: 帳戶安全狀態顯示
- [ ] **UI_Account_Option** - 帳戶選項設定頁面
- 設計要求: 帳戶設定和隱私控制
- 設計要求: 帳戶關聯和解綁功能
### 2.3 支援功能頁面群組 (第5-6週)
#### 2.3.1 系統介面和狀態頁面 📊 (優先級: P2)
**需設計的頁面**:
- [ ] **UI_Insufficient_Resources** - 資源不足提示頁面
- 設計要求: 清晰的資源不足說明
- 設計要求: 獲取資源的引導路徑
- [ ] **UI_LifePoints_Display** - 生命點數顯示組件
- 設計要求: 直觀的生命值視覺化
- UI規範: 命條生命系統 (ui-ux-guidelines.md 第30行)
- [ ] **UI_Subscription_Success** - 訂閱成功頁面
- 設計要求: 訂閱確認和權益說明
- 設計要求: 後續使用指引
- [ ] **UI_TimeWarp_Cards** - 時光卷使用介面
- 設計要求: 時光卷功能說明和使用確認
- 設計要求: 使用後效果展示
- [ ] **UI_LevelResult_SuccessResult** - 關卡成功結果頁面
- 設計要求: 成就慶祝動畫和統計展示
- UI規範: 即時成就反饋 (ui-ux-guidelines.md 第25行)
## 💻 階段三Web端企業級重設計 (第7-9週)
### 3.1 Web端專屬功能設計 (第7-8週)
**規格參考**: `/docs/02_design/function-specs/web/` 全部Web端規格
**設計重點**:
- [ ] **桌面優化界面**: 大螢幕佈局和多視窗支援
- [ ] **鍵盤導航**: 完整的鍵盤操作支援
- [ ] **批量操作**: 企業級批量管理功能
- [ ] **高級分析**: 詳細的學習分析和報告功能
**需設計的主要頁面**:
- [ ] **詞彙學習Web版**: 桌面優化的詞彙學習界面
- [ ] **情境對話Web版**: 大螢幕對話練習界面
- [ ] **學習地圖Web版**: 多層級地圖導航界面
- [ ] **道具商店Web版**: 企業級商店管理界面
- [ ] **用戶認證Web版**: SSO和企業登入支援
### 3.2 響應式設計和跨平台整合 (第8-9週)
**工作內容**:
- [ ] **響應式佈局**: Mobile → Tablet → Desktop 完整適配
- [ ] **跨瀏覽器相容性**: Chrome, Firefox, Safari, Edge 完整支援
- [ ] **效能優化**: 載入時間和互動回應最佳化
- [ ] **PWA功能**: 漸進式Web應用功能整合
## 🔧 階段四:設計系統完善和品質保證 (第10-12週)
### 4.1 設計系統文檔化和工具化 (第10-11週)
**工作內容**:
- [ ] **設計規範手冊**: 完整的設計規範使用指南
- [ ] **組件使用指南**: 每個組件的使用場景和最佳實踐
- [ ] **設計審查清單**: 品質控制清單和審查標準
- [ ] **維護指南**: 設計系統維護和更新流程
### 4.2 品質保證和使用性測試 (第11-12週)
**工作內容**:
- [ ] **設計一致性審查**: 跨平台設計一致性檢查
- [ ] **無障礙性測試**: WCAG 2.1 AA級合規驗證
- [ ] **使用性測試**: 用戶測試和回饋收集
- [ ] **效能評估**: 設計對系統效能的影響評估
## 📊 成功標準和驗收條件
### 🎯 品質標準
1. **功能規格符合度**: 100%符合所有功能規格要求
2. **設計一致性**: 跨平台設計語言100%統一
3. **無障礙標準**: WCAG 2.1 AA級100%合規
4. **效能標準**: 頁面載入時間<3秒互動回應時間<200ms
5. **瀏覽器支援**: 主流瀏覽器100%相容
### 📋 驗收清單
- [ ] 所有UI畫面符合對應功能規格文件要求
- [ ] 所有設計符合UI/UX規範標準
- [ ] 跨平台視覺一致性通過審查
- [ ] 無障礙功能測試全部通過
- [ ] 使用性測試滿意度≥90%
## 📁 交付物清單
### 🎨 設計文檔
- [ ] `ui-ux-guidelines.md` - 完善的UI/UX設計規範
- [ ] `design-system-documentation.md` - 設計系統完整文檔
- [ ] `component-library-guide.md` - 組件庫使用指南
- [ ] `responsive-design-standards.md` - 響應式設計標準
### 💻 設計資產
- [ ] `design-system.css` - 完整CSS設計系統
- [ ] 95+ HTML原型頁面 (Mobile + Web)
- [ ] 完整SVG圖標庫
- [ ] 設計系統展示網站
### 📋 支援文檔
- [ ] `design-review-checklist.md` - 設計審查清單
- [ ] `accessibility-compliance-report.md` - 無障礙合規報告
- [ ] `usability-test-results.md` - 使用性測試報告
- [ ] `maintenance-guidelines.md` - 維護指南
## 🚨 風險管控和品質保證
### ⚠️ 關鍵風險點
1. **規格理解偏差**: 設計不符合功能規格要求
- **控制措施**: 每個設計階段都進行規格文件交叉檢查
- **責任人**: 設計師必須深度閱讀相關規格文件
2. **設計一致性風險**: 跨頁面設計語言不統一
- **控制措施**: 建立設計審查機制,每週進行一致性檢查
- **工具支援**: 建立設計系統檢查清單
3. **無障礙合規風險**: 無障礙功能不完整
- **控制措施**: 每個組件設計完成都進行無障礙測試
- **標準遵循**: 嚴格遵循WCAG 2.1 AA級標準
### 🔍 品質控制機制
1. **階段性審查**: 每個階段結束進行全面審查
2. **同行評議**: 設計師之間相互審查和回饋
3. **用戶測試**: 關鍵頁面進行真實用戶測試
4. **技術可行性評估**: 設計與開發團隊聯合評估
## 📞 執行支援和溝通機制
### 🤝 團隊協作
- **設計團隊**: 負責設計執行和品質控制
- **產品團隊**: 提供功能需求解釋和使用者回饋
- **開發團隊**: 提供技術可行性建議和實現支援
- **測試團隊**: 提供品質測試和驗收支援
### 📋 進度追蹤
- **每週進度會議**: 檢討進度和解決阻礙
- **里程碑審查**: 階段性成果展示和評估
- **問題升級機制**: 重大問題快速上報和解決
- **文檔同步更新**: 確保所有團隊資訊同步
---
**📝 重要聲明**:
本計劃基於Drama Ling v3.0共用模組架構制定確保所有設計完全符合功能規格要求達到企業級應用標準。所有設計師在執行前必須深入理解相關功能規格文件和UI/UX規範確保設計品質和一致性。
**🎯 最終目標**:
創建Drama Ling史上最高品質的UI設計系統為用戶提供世界級的沉浸式英語學習體驗。
---
**最後更新**: 2025-01-15
**計劃版本**: v4.0 - 企業級重構
**執行週期**: 12週
**預期成果**: 95+ 企業級UI畫面

View File

@ -3,8 +3,27 @@
## 📋 當前任務
### 🔥 緊急任務
- [ ] 🔄 **前端架構重構Vue → 原生HTML** - 完全移除框架依賴實現100%設計還原 (3-4週)
- 📄 參考: [原生HTML重構專案](projects/native-html-migration.md)
- 🎯 關鍵: 設計精確度100%、Claude Code最佳化、性能提升、維護性提升
- 📋 合規基礎: 按照現有function-specs移除Vue/Quasar框架限制
- 🚀 **第一階段** (週1): 基礎架構搭建、核心CSS框架、JavaScript模組化
- 📱 **第二階段** (週1): 核心頁面實現 (首頁、認證、詞彙、對話、個人檔案)
- 🎮 **第三階段** (週1): 功能頁面實現 (練習、複習、分析儀表板、設定)
- 🔌 **第四階段** (週1): API整合、進階功能、測試與部署
- [x] 🏗️ **詞彙學習Web版 - 基礎架構建立** - Vue 3 + Quasar專案初始化嚴格對照HTML原型 (40小時) ✅ (2025-09-10)
- 📄 參考: [詞彙學習Web開發規劃](projects/vocabulary-learning-web-development-plan.md) [已歸檔]
- 🎯 關鍵: 280px側邊欄布局、CSS變數系統、vocabulary-card組件
- 📋 合規基礎: vocabulary.html原型 + vue-frontend-architecture.md
- ✨ 完成功能: Vue 3 + Quasar架構、280px側邊欄、CSS變數系統、VocabularyCard組件、TypeScript配置
- ⚠️ **重構決定**: 此架構將被原生HTML架構取代
- [x] 🎨 **詞彙介紹頁面完整實現** - Page_Vocab_Introduction_W像素級對照原型 (48小時) ✅ (2025-09-10)
- 📄 參考: [詞彙學習Web開發規劃](projects/vocabulary-learning-web-development-plan.md)
- 🎯 關鍵: 多列布局、Web Audio API、快捷鍵系統、筆記編輯器
- 📋 合規基礎: vocabulary-learning-web.md + vocabulary.html原型
- ✨ 完成功能: 多列響應式布局、完整快捷鍵系統、Markdown筆記編輯器、書籤整合、詞典整合、詞性色彩編碼、星級評分、例句音頻播放
### ⚠️ 重要任務
- [x] 🎮 **練習系統核心開發** - 選擇題、圖片匹配、句子重組三種模式 (56小時) ✅

51
apps/web/.eslintrc.js Normal file
View File

@ -0,0 +1,51 @@
module.exports = {
root: true,
env: {
node: true,
browser: true,
es2021: true
},
extends: [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/typescript/recommended',
'@vue/prettier'
],
parser: 'vue-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser',
ecmaVersion: 2021,
sourceType: 'module'
},
plugins: ['@typescript-eslint'],
rules: {
// Vue規則
'vue/multi-word-component-names': 'off',
'vue/no-unused-vars': 'error',
'vue/component-definition-name-casing': ['error', 'PascalCase'],
'vue/component-name-in-template-casing': ['error', 'PascalCase'],
// TypeScript規則
'@typescript-eslint/no-unused-vars': ['error', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_'
}],
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
// 一般規則
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'prefer-const': 'error',
'no-var': 'error',
'object-shorthand': 'error',
'prefer-template': 'error'
},
globals: {
defineProps: 'readonly',
defineEmits: 'readonly',
defineExpose: 'readonly',
withDefaults: 'readonly'
}
}

14
apps/web/.prettierrc Normal file
View File

@ -0,0 +1,14 @@
{
"semi": false,
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"trailingComma": "none",
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "avoid",
"endOfLine": "lf",
"vueIndentScriptAndStyle": false,
"htmlWhitespaceSensitivity": "ignore"
}

209
apps/web/auto-imports.d.ts vendored Normal file
View File

@ -0,0 +1,209 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
export {}
declare global {
const $q: typeof import('quasar')['$q']
const Dialog: typeof import('quasar')['Dialog']
const EffectScope: typeof import('vue')['EffectScope']
const Loading: typeof import('quasar')['Loading']
const Notify: typeof import('quasar')['Notify']
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const createPinia: typeof import('pinia')['createPinia']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const defineStore: typeof import('pinia')['defineStore']
const effectScope: typeof import('vue')['effectScope']
const getActivePinia: typeof import('pinia')['getActivePinia']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const mapActions: typeof import('pinia')['mapActions']
const mapGetters: typeof import('pinia')['mapGetters']
const mapState: typeof import('pinia')['mapState']
const mapStores: typeof import('pinia')['mapStores']
const mapWritableState: typeof import('pinia')['mapWritableState']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const pinia: typeof import('./src/stores/index')['pinia']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const setActivePinia: typeof import('pinia')['setActivePinia']
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const storeToRefs: typeof import('pinia')['storeToRefs']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useAudio: typeof import('./src/composables/useAudio')['useAudio']
const useAuthStore: typeof import('./src/stores/auth')['useAuthStore']
const useBrowserBookmarks: typeof import('./src/composables/useBrowserBookmarks')['useBrowserBookmarks']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useFetch: typeof import('@vueuse/core')['useFetch']
const useId: typeof import('vue')['useId']
const useKeyboard: typeof import('./src/composables/useKeyboard')['useKeyboard']
const useKeyboardShortcuts: typeof import('./src/composables/useKeyboardShortcuts')['useKeyboardShortcuts']
const useLearningStore: typeof import('./src/stores/learning')['useLearningStore']
const useLink: typeof import('vue-router')['useLink']
const useLocalStorage: typeof import('@vueuse/core')['useLocalStorage']
const useModel: typeof import('vue')['useModel']
const useMultiTabLearning: typeof import('./src/composables/useMultiTabLearning')['useMultiTabLearning']
const usePracticeStore: typeof import('./src/stores/practice')['usePracticeStore']
const useQuasar: typeof import('quasar')['useQuasar']
const useReviewStore: typeof import('./src/stores/review')['useReviewStore']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useSessionStorage: typeof import('@vueuse/core')['useSessionStorage']
const useSlots: typeof import('vue')['useSlots']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const useUIStore: typeof import('./src/stores/ui')['useUIStore']
const useUserStore: typeof import('./src/stores/user')['useUserStore']
const useVocabularyStore: typeof import('./src/stores/vocabulary')['useVocabularyStore']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}
// for vue template auto import
import { UnwrapRef } from 'vue'
declare module 'vue' {
interface GlobalComponents {}
interface ComponentCustomProperties {
readonly $q: UnwrapRef<typeof import('quasar')['$q']>
readonly Dialog: UnwrapRef<typeof import('quasar')['Dialog']>
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
readonly Loading: UnwrapRef<typeof import('quasar')['Loading']>
readonly Notify: UnwrapRef<typeof import('quasar')['Notify']>
readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']>
readonly computed: UnwrapRef<typeof import('vue')['computed']>
readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
readonly createPinia: UnwrapRef<typeof import('pinia')['createPinia']>
readonly customRef: UnwrapRef<typeof import('vue')['customRef']>
readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>
readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
readonly defineStore: UnwrapRef<typeof import('pinia')['defineStore']>
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
readonly getActivePinia: UnwrapRef<typeof import('pinia')['getActivePinia']>
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
readonly h: UnwrapRef<typeof import('vue')['h']>
readonly inject: UnwrapRef<typeof import('vue')['inject']>
readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']>
readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']>
readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']>
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
readonly mapActions: UnwrapRef<typeof import('pinia')['mapActions']>
readonly mapGetters: UnwrapRef<typeof import('pinia')['mapGetters']>
readonly mapState: UnwrapRef<typeof import('pinia')['mapState']>
readonly mapStores: UnwrapRef<typeof import('pinia')['mapStores']>
readonly mapWritableState: UnwrapRef<typeof import('pinia')['mapWritableState']>
readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']>
readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']>
readonly onActivated: UnwrapRef<typeof import('vue')['onActivated']>
readonly onBeforeMount: UnwrapRef<typeof import('vue')['onBeforeMount']>
readonly onBeforeRouteLeave: UnwrapRef<typeof import('vue-router')['onBeforeRouteLeave']>
readonly onBeforeRouteUpdate: UnwrapRef<typeof import('vue-router')['onBeforeRouteUpdate']>
readonly onBeforeUnmount: UnwrapRef<typeof import('vue')['onBeforeUnmount']>
readonly onBeforeUpdate: UnwrapRef<typeof import('vue')['onBeforeUpdate']>
readonly onDeactivated: UnwrapRef<typeof import('vue')['onDeactivated']>
readonly onErrorCaptured: UnwrapRef<typeof import('vue')['onErrorCaptured']>
readonly onMounted: UnwrapRef<typeof import('vue')['onMounted']>
readonly onRenderTracked: UnwrapRef<typeof import('vue')['onRenderTracked']>
readonly onRenderTriggered: UnwrapRef<typeof import('vue')['onRenderTriggered']>
readonly onScopeDispose: UnwrapRef<typeof import('vue')['onScopeDispose']>
readonly onServerPrefetch: UnwrapRef<typeof import('vue')['onServerPrefetch']>
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
readonly onWatcherCleanup: UnwrapRef<typeof import('vue')['onWatcherCleanup']>
readonly pinia: UnwrapRef<typeof import('./src/stores/index')['pinia']>
readonly provide: UnwrapRef<typeof import('vue')['provide']>
readonly reactive: UnwrapRef<typeof import('vue')['reactive']>
readonly readonly: UnwrapRef<typeof import('vue')['readonly']>
readonly ref: UnwrapRef<typeof import('vue')['ref']>
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
readonly setActivePinia: UnwrapRef<typeof import('pinia')['setActivePinia']>
readonly setMapStoreSuffix: UnwrapRef<typeof import('pinia')['setMapStoreSuffix']>
readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']>
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
readonly storeToRefs: UnwrapRef<typeof import('pinia')['storeToRefs']>
readonly toRaw: UnwrapRef<typeof import('vue')['toRaw']>
readonly toRef: UnwrapRef<typeof import('vue')['toRef']>
readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']>
readonly toValue: UnwrapRef<typeof import('vue')['toValue']>
readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']>
readonly unref: UnwrapRef<typeof import('vue')['unref']>
readonly useAttrs: UnwrapRef<typeof import('vue')['useAttrs']>
readonly useAudio: UnwrapRef<typeof import('./src/composables/useAudio')['useAudio']>
readonly useAuthStore: UnwrapRef<typeof import('./src/stores/auth')['useAuthStore']>
readonly useBrowserBookmarks: UnwrapRef<typeof import('./src/composables/useBrowserBookmarks')['useBrowserBookmarks']>
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']>
readonly useFetch: UnwrapRef<typeof import('@vueuse/core')['useFetch']>
readonly useId: UnwrapRef<typeof import('vue')['useId']>
readonly useKeyboard: UnwrapRef<typeof import('./src/composables/useKeyboard')['useKeyboard']>
readonly useKeyboardShortcuts: UnwrapRef<typeof import('./src/composables/useKeyboardShortcuts')['useKeyboardShortcuts']>
readonly useLearningStore: UnwrapRef<typeof import('./src/stores/learning')['useLearningStore']>
readonly useLink: UnwrapRef<typeof import('vue-router')['useLink']>
readonly useLocalStorage: UnwrapRef<typeof import('@vueuse/core')['useLocalStorage']>
readonly useModel: UnwrapRef<typeof import('vue')['useModel']>
readonly useMultiTabLearning: UnwrapRef<typeof import('./src/composables/useMultiTabLearning')['useMultiTabLearning']>
readonly usePracticeStore: UnwrapRef<typeof import('./src/stores/practice')['usePracticeStore']>
readonly useQuasar: UnwrapRef<typeof import('quasar')['useQuasar']>
readonly useReviewStore: UnwrapRef<typeof import('./src/stores/review')['useReviewStore']>
readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']>
readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']>
readonly useSessionStorage: UnwrapRef<typeof import('@vueuse/core')['useSessionStorage']>
readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']>
readonly useTemplateRef: UnwrapRef<typeof import('vue')['useTemplateRef']>
readonly useUIStore: UnwrapRef<typeof import('./src/stores/ui')['useUIStore']>
readonly useUserStore: UnwrapRef<typeof import('./src/stores/user')['useUserStore']>
readonly useVocabularyStore: UnwrapRef<typeof import('./src/stores/vocabulary')['useVocabularyStore']>
readonly watch: UnwrapRef<typeof import('vue')['watch']>
readonly watchEffect: UnwrapRef<typeof import('vue')['watchEffect']>
readonly watchPostEffect: UnwrapRef<typeof import('vue')['watchPostEffect']>
readonly watchSyncEffect: UnwrapRef<typeof import('vue')['watchSyncEffect']>
}
}

31
apps/web/components.d.ts vendored Normal file
View File

@ -0,0 +1,31 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
BaseButton: typeof import('./src/components/base/BaseButton.vue')['default']
BaseCard: typeof import('./src/components/base/BaseCard.vue')['default']
BaseInput: typeof import('./src/components/base/BaseInput.vue')['default']
BaseModal: typeof import('./src/components/base/BaseModal.vue')['default']
ErrorHeatmap: typeof import('./src/components/dashboard/ErrorHeatmap.vue')['default']
Icon: typeof import('./src/components/ui/Icon.vue')['default']
ModalContainer: typeof import('./src/components/ui/ModalContainer.vue')['default']
PWAInstallPrompt: typeof import('./src/components/PWAInstallPrompt.vue')['default']
QBadge: typeof import('quasar')['QBadge']
QBreadcrumbs: typeof import('quasar')['QBreadcrumbs']
QBreadcrumbsEl: typeof import('quasar')['QBreadcrumbsEl']
QBtn: typeof import('quasar')['QBtn']
QCheckbox: typeof import('quasar')['QCheckbox']
QIcon: typeof import('quasar')['QIcon']
QSpinner: typeof import('quasar')['QSpinner']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
StatCard: typeof import('./src/components/dashboard/StatCard.vue')['default']
ToastContainer: typeof import('./src/components/ui/ToastContainer.vue')['default']
VocabularyCard: typeof import('./src/components/business/VocabularyCard.vue')['default']
}
}

View File

@ -0,0 +1 @@
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })

View File

@ -1,17 +1,103 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Drama Ling - 詞彙學習</title>
<link rel="stylesheet" href="./src/styles/main.scss">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Drama Ling - AI語言學習</title>
<meta name="description" content="AI驅動的情境式語言學習應用透過真實對話場景提升語言能力">
<meta name="keywords" content="語言學習,AI,英語,對話,情境學習">
<meta name="author" content="Drama Ling Team">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://dramaling.com/">
<meta property="og:title" content="Drama Ling - AI語言學習">
<meta property="og:description" content="AI驅動的情境式語言學習應用">
<meta property="og:image" content="/og-image.jpg">
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image">
<meta property="twitter:url" content="https://dramaling.com/">
<meta property="twitter:title" content="Drama Ling - AI語言學習">
<meta property="twitter:description" content="AI驅動的情境式語言學習應用">
<meta property="twitter:image" content="/og-image.jpg">
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="icon" type="image/png" href="/favicon.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<!-- Theme Color -->
<meta name="theme-color" content="#00E5CC">
<meta name="msapplication-TileColor" content="#00E5CC">
</head>
<body>
<div id="app">
<div class="loading-spinner">
載入中...
</div>
</div>
<script type="module" src="./src/main.js"></script>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<!-- 載入畫面 -->
<style>
#loading-screen {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #2C3E50;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 9999;
}
.loading-logo {
width: 80px;
height: 80px;
border: 4px solid #34495E;
border-top: 4px solid #00E5CC;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 20px;
}
.loading-text {
color: #B8BCC8;
font-family: 'Inter', sans-serif;
font-size: 16px;
font-weight: 500;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
<div id="loading-screen">
<div class="loading-logo"></div>
<div class="loading-text">Drama Ling 載入中...</div>
</div>
<script>
// 移除載入畫面
window.addEventListener('load', function() {
setTimeout(function() {
const loadingScreen = document.getElementById('loading-screen');
if (loadingScreen) {
loadingScreen.style.opacity = '0';
loadingScreen.style.transition = 'opacity 0.5s ease';
setTimeout(function() {
loadingScreen.remove();
}, 500);
}
}, 500);
});
</script>
</body>
</html>

10804
apps/web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,22 +1,68 @@
{
"name": "web-native",
"name": "dramaling-web",
"version": "1.0.0",
"description": "",
"main": "index.js",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"dev": "vite --host",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"test": "echo \"Error: no test specified\" && exit 1"
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"test:e2e": "cypress run",
"test:e2e:dev": "cypress open",
"lint": "eslint . --ext .vue,.ts,.tsx --fix",
"lint:style": "stylelint **/*.{css,scss,vue} --fix",
"type-check": "vue-tsc --noEmit",
"prepare": "husky install"
},
"dependencies": {
"@quasar/extras": "^1.16.4",
"@vueuse/core": "^10.9.0",
"axios": "^1.6.8",
"chart.js": "^4.5.0",
"dayjs": "^1.11.10",
"dompurify": "^3.0.11",
"jspdf": "^3.0.2",
"lodash-es": "^4.17.21",
"pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.1",
"quasar": "^2.16.0",
"vee-validate": "^4.12.6",
"vue": "^3.4.21",
"vue-chartjs": "^5.3.2",
"vue-router": "^4.3.0",
"workbox-window": "^7.0.0",
"xlsx": "^0.18.5",
"yup": "^1.4.0"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"devDependencies": {
"@vitejs/plugin-legacy": "^7.2.1",
"sass": "^1.92.1",
"terser": "^5.44.0",
"vite": "^7.1.5"
"@quasar/vite-plugin": "^1.6.0",
"@types/dompurify": "^3.0.5",
"@types/lodash-es": "^4.17.12",
"@types/node": "^20.12.7",
"@vitejs/plugin-vue": "^5.0.4",
"@vitest/coverage-v8": "^1.5.0",
"@vitest/ui": "^1.5.0",
"@vue/eslint-config-prettier": "^9.0.0",
"@vue/eslint-config-typescript": "^13.0.0",
"@vue/test-utils": "^2.4.5",
"cypress": "^13.7.2",
"eslint": "^9.1.1",
"happy-dom": "^14.7.1",
"husky": "^9.0.11",
"lint-staged": "^15.2.2",
"prettier": "^3.2.5",
"sass": "^1.77.0",
"stylelint": "^16.4.0",
"stylelint-config-standard-scss": "^13.1.0",
"stylelint-config-standard-vue": "^1.0.0",
"typescript": "^5.4.0",
"unplugin-auto-import": "^0.17.5",
"unplugin-vue-components": "^0.27.0",
"vite": "^5.2.0",
"vite-plugin-pwa": "^0.20.0",
"vitest": "^1.5.0",
"vue-tsc": "^2.0.6"
}
}

View File

@ -0,0 +1,68 @@
<!DOCTYPE html>
<html>
<head>
<title>Debug Page</title>
<style>
body { font-family: Arial, sans-serif; padding: 20px; }
.test { background: #f0f0f0; padding: 10px; margin: 10px 0; }
</style>
</head>
<body>
<h1>調試頁面</h1>
<div class="test">
<h3>基礎測試</h3>
<p>✅ HTML正常顯示</p>
<p id="js-test">⏳ JavaScript測試中...</p>
</div>
<div class="test">
<h3>網絡測試</h3>
<p id="main-js">⏳ 檢查main.js...</p>
<p id="vue-app">⏳ 檢查Vue應用...</p>
</div>
<div class="test">
<h3>控制台訊息</h3>
<p>請打開瀏覽器開發者工具(F12) -> Console面板查看是否有錯誤訊息</p>
</div>
<script>
console.log('=== Debug Page Loaded ===');
// 基礎JavaScript測試
document.getElementById('js-test').innerHTML = '✅ JavaScript正常執行';
// 檢查main.js是否可以載入
fetch('/src/main.ts')
.then(response => {
document.getElementById('main-js').innerHTML =
response.ok ? '✅ main.ts可以訪問' : '❌ main.ts無法訪問';
})
.catch(err => {
document.getElementById('main-js').innerHTML = '❌ main.ts載入失敗: ' + err.message;
});
// 檢查Vue應用DOM
setTimeout(() => {
const app = document.querySelector('#app');
if (app) {
const isEmpty = app.innerHTML.trim() === '';
document.getElementById('vue-app').innerHTML =
isEmpty ? '❌ Vue應用DOM為空' : '✅ Vue應用DOM有內容';
console.log('Vue app content:', app.innerHTML);
} else {
document.getElementById('vue-app').innerHTML = '❌ 找不到#app元素';
}
}, 2000);
// 檢查Vue是否載入
setTimeout(() => {
if (window.Vue) {
console.log('✅ Vue已載入:', window.Vue);
} else {
console.log('❌ Vue未載入');
}
}, 3000);
</script>
</body>
</html>

View File

@ -0,0 +1,8 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" rx="6" fill="#00E5CC"/>
<path d="M8 16C8 11.5817 11.5817 8 16 8C20.4183 8 24 11.5817 24 16C24 20.4183 20.4183 24 16 24C11.5817 24 8 20.4183 8 16Z" fill="white"/>
<path d="M12 14H20V18H12V14Z" fill="#00E5CC"/>
<circle cx="14" cy="16" r="1" fill="white"/>
<circle cx="18" cy="16" r="1" fill="white"/>
<path d="M14 18C14 19.1046 14.8954 20 16 20C17.1046 20 18 19.1046 18 18" stroke="white" stroke-width="1" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 572 B

View File

@ -0,0 +1,136 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>混合式開發方案 - Drama Ling</title>
<style>
/* 完全自定義樣式 - 沒有框架干擾 */
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', 'Noto Sans TC', -apple-system, BlinkMacSystemFont, sans-serif;
background: #F7F9FC;
color: #2C3E50;
}
.container { max-width: 1200px; margin: 0 auto; padding: 2rem; }
.card { background: white; border-radius: 1rem; padding: 2rem; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); }
.btn { background: #00E5CC; color: white; border: none; padding: 0.75rem 1.5rem; border-radius: 0.5rem; cursor: pointer; }
.btn:hover { background: #00D4B8; }
</style>
</head>
<body>
<div class="container">
<div class="card">
<h1>混合式開發方案</h1>
<p>靜態佈局 + 動態功能</p>
<!-- 靜態內容純HTML -->
<div class="static-content">
<h2>學習統計 (靜態展示)</h2>
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; margin: 1rem 0;">
<div class="card" style="text-align: center;">
<div style="font-size: 2rem; font-weight: bold;">1,247</div>
<div style="color: #64748B;">總詞彙</div>
</div>
<div class="card" style="text-align: center;">
<div style="font-size: 2rem; font-weight: bold;">856</div>
<div style="color: #64748B;">已掌握</div>
</div>
<div class="card" style="text-align: center;">
<div style="font-size: 2rem; font-weight: bold;">23</div>
<div style="color: #64748B;">待複習</div>
</div>
<div class="card" style="text-align: center;">
<div style="font-size: 2rem; font-weight: bold;">368</div>
<div style="color: #64748B;">學習中</div>
</div>
</div>
</div>
<!-- 動態內容需要JavaScript邏輯的部分 -->
<div class="dynamic-content">
<h2>練習選擇 (動態交互)</h2>
<div id="practice-selector">
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin: 1rem 0;">
<div class="card practice-card" @click="selectPractice('choice')" :class="{ active: selectedPractice === 'choice' }">
<h3>選擇題練習</h3>
<p>測試詞彙定義理解</p>
</div>
<div class="card practice-card" @click="selectPractice('translation')" :class="{ active: selectedPractice === 'translation' }">
<h3>翻譯練習</h3>
<p>英中翻譯能力測試</p>
</div>
<div class="card practice-card" @click="selectPractice('synonym')" :class="{ active: selectedPractice === 'synonym' }">
<h3>同義詞練習</h3>
<p>詞彙關聯性訓練</p>
</div>
</div>
<button class="btn" @click="startPractice" :disabled="!selectedPractice">
開始練習
</button>
<div v-if="selectedPractice" style="margin-top: 1rem; color: #64748B;">
已選擇:{{ practiceTypes[selectedPractice] }}
</div>
</div>
</div>
</div>
</div>
<!-- 只在需要的地方引入Vue -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
// 只針對需要狀態管理的組件使用Vue
const { createApp } = Vue
createApp({
data() {
return {
selectedPractice: null,
practiceTypes: {
choice: '選擇題練習',
translation: '翻譯練習',
synonym: '同義詞練習'
}
}
},
methods: {
selectPractice(type) {
this.selectedPractice = type
},
startPractice() {
if (this.selectedPractice) {
alert(`開始${this.practiceTypes[this.selectedPractice]}`)
// 這裡可以跳轉到實際的練習頁面
window.location.href = `/practice-${this.selectedPractice}.html`
}
}
}
}).mount('#practice-selector')
</script>
<style>
.practice-card {
cursor: pointer;
transition: all 0.2s ease;
border: 2px solid transparent;
}
.practice-card:hover {
transform: translateY(-2px);
border-color: #00E5CC;
}
.practice-card.active {
border-color: #00E5CC;
background: #F0FDFA;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
</body>
</html>

8
apps/web/public/logo.svg Normal file
View File

@ -0,0 +1,8 @@
<svg width="192" height="192" viewBox="0 0 192 192" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="192" height="192" rx="32" fill="#00E5CC"/>
<path d="M48 96C48 69.4903 69.4903 48 96 48C122.51 48 144 69.4903 144 96C144 122.51 122.51 144 96 144C69.4903 144 48 122.51 48 96Z" fill="white"/>
<path d="M72 84H120V108H72V84Z" fill="#00E5CC"/>
<circle cx="84" cy="96" r="6" fill="white"/>
<circle cx="108" cy="96" r="6" fill="white"/>
<path d="M84 108C84 114.627 89.3726 120 96 120C102.627 120 108 114.627 108 108" stroke="white" stroke-width="4" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 598 B

14
apps/web/public/test.html Normal file
View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<title>簡單測試頁面</title>
</head>
<body>
<h1>這是一個簡單的HTML測試頁面</h1>
<p>如果你能看到這個,說明服務器正常</p>
<script>
console.log('JavaScript 正常執行')
document.body.innerHTML += '<p>JavaScript 也正常工作</p>'
</script>
</body>
</html>

View File

@ -0,0 +1,360 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>詞彙學習 - Drama Ling</title>
<style>
/* 重置樣式 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', 'Noto Sans TC', -apple-system, BlinkMacSystemFont, sans-serif;
background: #F7F9FC;
color: #2C3E50;
line-height: 1.6;
}
/* 容器 */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
/* 頁面標題 */
.page-header {
text-align: center;
margin-bottom: 3rem;
}
.page-title {
font-size: 2.5rem;
font-weight: 800;
color: #2C3E50;
margin-bottom: 0.5rem;
}
.page-subtitle {
font-size: 1.25rem;
color: #64748B;
}
/* 統計卡片 */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 3rem;
}
.stat-card {
background: white;
border-radius: 1rem;
padding: 1.5rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px -3px rgba(0, 0, 0, 0.1);
}
.stat-content {
display: flex;
align-items: center;
gap: 1rem;
}
.stat-icon {
width: 3rem;
height: 3rem;
background: #00E5CC;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
color: white;
}
.stat-info {
flex: 1;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
color: #2C3E50;
}
.stat-label {
font-size: 0.875rem;
color: #64748B;
}
/* 練習模式 */
.practice-section {
margin-bottom: 3rem;
}
.section-title {
font-size: 1.75rem;
font-weight: 700;
color: #2C3E50;
margin-bottom: 1.5rem;
}
.practice-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 1.5rem;
}
.practice-card {
background: white;
border-radius: 1rem;
padding: 2rem;
text-align: center;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
cursor: pointer;
border: 2px solid transparent;
}
.practice-card:hover {
transform: translateY(-4px);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
border-color: #00E5CC;
}
.practice-icon {
width: 4rem;
height: 4rem;
background: linear-gradient(135deg, #00E5CC, #6366F1);
border-radius: 1rem;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 1rem;
font-size: 2rem;
color: white;
}
.practice-card h3 {
font-size: 1.25rem;
font-weight: 600;
color: #2C3E50;
margin-bottom: 0.5rem;
}
.practice-description {
font-size: 0.875rem;
color: #64748B;
margin-bottom: 1rem;
}
.practice-meta {
display: flex;
gap: 0.5rem;
justify-content: center;
flex-wrap: wrap;
}
.chip {
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.chip-primary {
background: #00E5CC;
color: white;
}
.chip-outline {
border: 1px solid #E2E8F0;
color: #64748B;
}
/* 開始按鈕 */
.start-button {
background: linear-gradient(135deg, #00E5CC, #6366F1);
color: white;
border: none;
padding: 0.75rem 2rem;
border-radius: 0.75rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
margin-top: 2rem;
}
.start-button:hover {
transform: translateY(-1px);
box-shadow: 0 10px 25px -3px rgba(0, 229, 204, 0.5);
}
/* 響應式設計 */
@media (max-width: 768px) {
.container {
padding: 1rem;
}
.page-title {
font-size: 2rem;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.practice-grid {
grid-template-columns: 1fr;
}
}
/* 動畫 */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-in-up {
animation: fadeInUp 0.6s ease-out;
}
</style>
</head>
<body>
<div class="container">
<!-- 頁面標題 -->
<div class="page-header fade-in-up">
<h1 class="page-title">詞彙學習中心</h1>
<p class="page-subtitle">透過多種練習模式提升你的詞彙量</p>
</div>
<!-- 學習統計 -->
<div class="stats-grid">
<div class="stat-card fade-in-up" style="animation-delay: 0.1s;">
<div class="stat-content">
<div class="stat-icon">📚</div>
<div class="stat-info">
<div class="stat-value">1,247</div>
<div class="stat-label">總詞彙數</div>
</div>
</div>
</div>
<div class="stat-card fade-in-up" style="animation-delay: 0.2s;">
<div class="stat-content">
<div class="stat-icon"></div>
<div class="stat-info">
<div class="stat-value">856</div>
<div class="stat-label">已掌握</div>
</div>
</div>
</div>
<div class="stat-card fade-in-up" style="animation-delay: 0.3s;">
<div class="stat-content">
<div class="stat-icon"></div>
<div class="stat-info">
<div class="stat-value">23</div>
<div class="stat-label">待複習</div>
</div>
</div>
</div>
<div class="stat-card fade-in-up" style="animation-delay: 0.4s;">
<div class="stat-content">
<div class="stat-icon">🎯</div>
<div class="stat-info">
<div class="stat-value">368</div>
<div class="stat-label">學習中</div>
</div>
</div>
</div>
</div>
<!-- 練習模式 -->
<div class="practice-section">
<h2 class="section-title fade-in-up">快速開始</h2>
<div class="practice-grid">
<div class="practice-card fade-in-up" style="animation-delay: 0.1s;" onclick="startPractice('choice')">
<div class="practice-icon">🧠</div>
<h3>選擇題練習</h3>
<p class="practice-description">測試詞彙定義理解</p>
<div class="practice-meta">
<span class="chip chip-primary">10題</span>
<span class="chip chip-outline">基礎-中級</span>
</div>
</div>
<div class="practice-card fade-in-up" style="animation-delay: 0.2s;" onclick="startPractice('translation')">
<div class="practice-icon">🌐</div>
<h3>翻譯練習</h3>
<p class="practice-description">英中翻譯能力測試</p>
<div class="practice-meta">
<span class="chip chip-primary">10題</span>
<span class="chip chip-outline">中級-高級</span>
</div>
</div>
<div class="practice-card fade-in-up" style="animation-delay: 0.3s;" onclick="startPractice('synonym')">
<div class="practice-icon">🔄</div>
<h3>同義詞練習</h3>
<p class="practice-description">詞彙關聯性訓練</p>
<div class="practice-meta">
<span class="chip chip-primary">10題</span>
<span class="chip chip-outline">高級</span>
</div>
</div>
</div>
<div style="text-align: center;">
<button class="start-button fade-in-up" style="animation-delay: 0.4s;" onclick="customPractice()">
自定義練習設定
</button>
</div>
</div>
</div>
<script>
// 練習功能
function startPractice(type) {
alert(`開始${type}練習!`);
// 這裡可以跳轉到實際的練習頁面
}
function customPractice() {
alert('打開自定義練習設定!');
}
// 頁面載入動畫
document.addEventListener('DOMContentLoaded', function() {
const elements = document.querySelectorAll('.fade-in-up');
elements.forEach((el, index) => {
el.style.opacity = '0';
setTimeout(() => {
el.style.opacity = '1';
}, index * 100);
});
});
</script>
</body>
</html>

34
apps/web/src/App.vue Normal file
View File

@ -0,0 +1,34 @@
<template>
<div id="app">
<router-view />
<PWAInstallPrompt />
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import PWAInstallPrompt from '@/components/PWAInstallPrompt.vue'
const authStore = useAuthStore()
onMounted(async () => {
console.log('App.vue mounted, initializing auth...')
await authStore.initialize()
console.log('Auth initialized, isAuthenticated:', authStore.isAuthenticated)
})
</script>
<style>
#app {
font-family: 'Inter', 'Noto Sans TC', -apple-system, BlinkMacSystemFont, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: #2C3E50;
background: #F7F9FC;
min-height: 100vh;
width: 100%;
margin: 0;
padding: 0;
}
</style>

View File

@ -0,0 +1,328 @@
// Drama Ling 主要樣式檔案
@import './variables';
// ===== 全域重置 =====
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
line-height: 1.6;
}
body {
font-family: $font-family-primary;
background: $background-primary;
color: $text-primary;
overflow-x: hidden;
}
// ===== 全域樣式類 =====
// 文字樣式
.text-primary { color: $text-primary; }
.text-secondary { color: $text-secondary; }
.text-tertiary { color: $text-tertiary; }
.text-xs { font-size: $text-xs; }
.text-sm { font-size: $text-sm; }
.text-base { font-size: $text-base; }
.text-lg { font-size: $text-lg; }
.text-xl { font-size: $text-xl; }
.text-2xl { font-size: $text-2xl; }
.text-3xl { font-size: $text-3xl; }
.text-4xl { font-size: $text-4xl; }
// 背景樣式
.bg-primary { background: $background-primary; }
.bg-secondary { background: $background-secondary; }
.bg-dark { background: $background-dark; }
.bg-card { background: $card-background; }
// 間距工具類
.p-1 { padding: $space-1; }
.p-2 { padding: $space-2; }
.p-3 { padding: $space-3; }
.p-4 { padding: $space-4; }
.p-5 { padding: $space-5; }
.p-6 { padding: $space-6; }
.p-8 { padding: $space-8; }
.m-1 { margin: $space-1; }
.m-2 { margin: $space-2; }
.m-3 { margin: $space-3; }
.m-4 { margin: $space-4; }
.m-5 { margin: $space-5; }
.m-6 { margin: $space-6; }
.m-8 { margin: $space-8; }
// ===== 動畫效果 =====
// 頁面轉場
.page-enter-active,
.page-leave-active {
transition: all 0.3s ease;
}
.page-enter-from {
opacity: 0;
transform: translateY(20px);
}
.page-leave-to {
opacity: 0;
transform: translateY(-20px);
}
// 彈出動畫
@keyframes popup {
0% {
transform: scale(0) rotate(-360deg);
opacity: 0;
}
100% {
transform: scale(1) rotate(0deg);
opacity: 1;
}
}
.popup-enter {
animation: popup 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55) forwards;
}
// 脈衝動畫
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
.pulse {
animation: pulse 2s infinite;
}
// 旋轉動畫
@keyframes rotate {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.rotate {
animation: rotate 2s linear infinite;
}
// ===== 滾動條樣式 =====
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: $background-secondary;
border-radius: $radius-sm;
}
::-webkit-scrollbar-thumb {
background: $primary-teal;
border-radius: $radius-sm;
&:hover {
background: $primary-teal-light;
}
}
// ===== 響應式工具類 =====
.hidden { display: none; }
@include respond-to(xs) {
.hidden-xs { display: none; }
.visible-xs { display: block; }
}
@include respond-to(sm) {
.hidden-sm { display: none; }
.visible-sm { display: block; }
}
@include respond-to(md) {
.hidden-md { display: none; }
.visible-md { display: block; }
}
@include respond-to(lg) {
.hidden-lg { display: none; }
.visible-lg { display: block; }
}
// ===== 按鈕基礎樣式 =====
.btn-base {
display: inline-flex;
align-items: center;
justify-content: center;
gap: $space-2;
font-weight: 600;
border: none;
border-radius: $radius-lg;
cursor: pointer;
transition: all 0.3s ease;
text-decoration: none;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.btn-primary {
@extend .btn-base;
background: $primary-teal;
color: $background-dark;
padding: $space-4 $space-6;
&:hover:not(:disabled) {
background: $primary-teal-light;
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 229, 204, 0.3);
}
}
.btn-secondary {
@extend .btn-base;
background: transparent;
color: $primary-teal;
border: 2px solid $primary-teal;
padding: $space-3 $space-5;
&:hover:not(:disabled) {
background: rgba($primary-teal, 0.1);
transform: translateY(-1px);
}
}
// ===== 輸入框基礎樣式 =====
.input-base {
width: 100%;
padding: $space-4 $space-5;
background: $background-secondary;
border: 2px solid $divider;
border-radius: $radius-lg;
font-size: $text-base;
color: $text-primary;
transition: all 0.3s ease;
&::placeholder {
color: $text-secondary;
}
&:focus {
outline: none;
background: $card-background;
border-color: $primary-teal;
box-shadow: 0 0 0 4px rgba(0, 229, 204, 0.15);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
// ===== 卡片基礎樣式 =====
.card-base {
background: $card-background;
border-radius: $radius-xl;
padding: $space-6;
@include card-shadow(1);
border: 1px solid $divider;
transition: all 0.3s ease;
&:hover {
transform: translateY(-2px);
@include card-shadow(2);
}
}
// ===== 遊戲化元素樣式 =====
.level-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 32px;
height: 24px;
background: $level-background;
color: white;
border-radius: $radius-full;
font-size: $text-sm;
font-weight: 700;
padding: 0 $space-2;
}
.exp-bar {
height: 8px;
background: $background-secondary;
border-radius: $radius-full;
overflow: hidden;
&-fill {
height: 100%;
background: linear-gradient(90deg, $primary-teal, $primary-teal-light);
border-radius: $radius-full;
transition: width 1s ease;
}
}
.star-rating {
display: inline-flex;
gap: $space-1;
.star {
width: 16px;
height: 16px;
color: $star-inactive;
&.active {
color: $star-active;
}
}
}
// ===== 無障礙樣式 =====
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
// 焦點樣式
*:focus-visible {
outline: 2px solid $primary-teal;
outline-offset: 2px;
}
// ===== 載入狀態 =====
.loading-spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 2px solid transparent;
border-top: 2px solid $primary-teal;
border-radius: 50%;
animation: rotate 1s linear infinite;
}

View File

@ -0,0 +1,56 @@
// Quasar SASS Variables
// This file is used by Quasar to customize default component styles
// Brand colors
$primary : #1976D2
$secondary : #26A69A
$accent : #9C27B0
$dark : #1D1D1D
$dark-page : #121212
$positive : #21BA45
$negative : #C10015
$info : #31CCEC
$warning : #F2C037
// Typography
$h1 : 2rem
$h2 : 1.5rem
$h3 : 1.25rem
$h4 : 1.125rem
$h5 : 1rem
$h6 : 0.875rem
$body-font-size : 0.875rem
$body-line-height : 1.5
// Spacing
$space-xs : 0.25rem
$space-sm : 0.5rem
$space-md : 1rem
$space-lg : 1.5rem
$space-xl : 3rem
// Borders
$generic-border-radius : 4px
$button-border-radius : 4px
$input-border-radius : 4px
// Shadows
$shadow-1: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24)
$shadow-2: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23)
// Custom Drama Ling Theme Variables
$drama-primary : #00E5CC
$drama-secondary : #FF6B6B
$drama-accent : #4ECDC4
$drama-background : #F7F9FC
$drama-surface : #FFFFFF
$drama-text : #2C3E50
$drama-text-light : #7F8C8D
// Override Quasar defaults with Drama Ling theme
$primary : $drama-primary
$secondary : $drama-secondary
$accent : $drama-accent

View File

@ -0,0 +1,173 @@
// Drama Ling Design System Variables
// ===== 色彩系統 =====
// 主要品牌色 - 青綠色
$primary-teal: #00E5CC;
$primary-teal-light: #33E8D1;
$primary-teal-dark: #00B3A0;
// 輔助色 - 紫色系
$secondary-purple: #8E44AD;
$secondary-purple-light: #A569BD;
$secondary-purple-dark: #6C3483;
// 強調色 - 活力紫
$accent-violet: #9B59B6;
$accent-violet-light: #BB8FCE;
$accent-violet-dark: #7D3C98;
// 功能性色彩
$error-red: #E74C3C;
$warning-yellow: #F39C12;
$warning-orange: #F39C12; // 別名
$success-green: #00E5CC;
$info-cyan: #3498DB;
// 暗色主題色調
$text-primary: #FFFFFF;
$text-primary-inverse: #2C3E50; // 反色文字
$text-secondary: #B8BCC8;
$text-tertiary: #7F8C8D;
$background-primary: #2C3E50;
$background-secondary: #34495E;
$background-dark: #1A252F;
$divider: #4A5568;
$card-background: #3A4A5C;
// 遊戲化色彩
$star-active: #F1C40F;
$star-inactive: #7F8C8D;
$bronze: #CD7F32;
$silver: #C0C0C0;
$gold: #FFD700;
$diamond: #B9F2FF;
$exp-bar: #00E5CC;
$level-background: #8E44AD;
$achievement-glow: #F39C12;
// ===== 字體系統 =====
// 字體家族
$font-family-primary: 'Inter', 'PingFang TC', -apple-system, sans-serif;
$font-family-secondary: 'Roboto', 'Microsoft JhengHei UI', sans-serif;
$font-family-mono: 'JetBrains Mono', 'SF Mono', Monaco, monospace;
// 字體大小
$text-xs: 0.75rem; // 12px
$text-sm: 0.875rem; // 14px
$text-base: 1rem; // 16px
$text-lg: 1.125rem; // 18px
$text-xl: 1.25rem; // 20px
$text-2xl: 1.5rem; // 24px
$text-3xl: 1.875rem; // 30px
$text-4xl: 2.25rem; // 36px
// 遊戲化特殊字體
$text-game-score: 1.5rem; // 24px
$text-game-level: 0.875rem; // 14px
$text-game-title: 1.25rem; // 20px
// ===== 間距系統 =====
$space-1: 0.25rem; // 4px
$space-2: 0.5rem; // 8px
$space-3: 0.75rem; // 12px
$space-4: 1rem; // 16px
$space-5: 1.25rem; // 20px
$space-6: 1.5rem; // 24px
$space-8: 2rem; // 32px
$space-10: 2.5rem; // 40px
$space-12: 3rem; // 48px
$space-16: 4rem; // 64px
$space-20: 5rem; // 80px
// ===== 圓角和陰影 =====
$radius-sm: 0.5rem; // 8px
$radius-md: 0.75rem; // 12px
$radius-lg: 1rem; // 16px
$radius-xl: 1.5rem; // 24px
$radius-2xl: 2rem; // 32px
$radius-full: 50%;
// 陰影系統
$shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
$shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
$shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
$shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
// ===== 響應式斷點 =====
$breakpoint-xs: 0;
$breakpoint-sm: 640px;
$breakpoint-md: 768px;
$breakpoint-lg: 1024px;
$breakpoint-xl: 1280px;
$breakpoint-2xl: 1536px;
// ===== Z-index 層級 =====
$z-sidebar: 900;
$z-mobile-nav: 950;
$z-dropdown: 1000;
$z-modal: 1050;
$z-popover: 1060;
$z-tooltip: 1070;
$z-toast: 1080;
// ===== 混合器 =====
@mixin respond-to($breakpoint) {
@if $breakpoint == xs {
@media (max-width: #{$breakpoint-sm - 1px}) { @content; }
}
@if $breakpoint == sm {
@media (min-width: #{$breakpoint-sm}) and (max-width: #{$breakpoint-md - 1px}) { @content; }
}
@if $breakpoint == md {
@media (min-width: #{$breakpoint-md}) and (max-width: #{$breakpoint-lg - 1px}) { @content; }
}
@if $breakpoint == lg {
@media (min-width: #{$breakpoint-lg}) and (max-width: #{$breakpoint-xl - 1px}) { @content; }
}
@if $breakpoint == xl {
@media (min-width: #{$breakpoint-xl}) { @content; }
}
}
@mixin text-ellipsis($lines: 1) {
@if $lines == 1 {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} @else {
display: -webkit-box;
-webkit-line-clamp: $lines;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
@mixin card-shadow($level: 1) {
@if $level == 1 {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
@if $level == 2 {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.12), 0 2px 4px rgba(0, 0, 0, 0.08);
}
@if $level == 3 {
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15), 0 4px 8px rgba(0, 0, 0, 0.1);
}
}
@mixin loading-skeleton {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
}
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}

View File

@ -1,101 +0,0 @@
// Base Web Component Class
export class BaseComponent extends HTMLElement {
constructor() {
super();
this.state = {};
this.listeners = [];
}
// Lifecycle methods
connectedCallback() {
this.render();
this.bindEvents();
}
disconnectedCallback() {
this.cleanup();
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
this.onAttributeChanged(name, oldValue, newValue);
}
}
// State management
setState(newState) {
this.state = { ...this.state, ...newState };
this.render();
}
getState() {
return { ...this.state };
}
// Event handling
addEventListener(element, event, handler, options = {}) {
element.addEventListener(event, handler, options);
this.listeners.push({ element, event, handler, options });
}
cleanup() {
this.listeners.forEach(({ element, event, handler, options }) => {
element.removeEventListener(event, handler, options);
});
this.listeners = [];
}
// Template methods (to be overridden)
render() {
// Override in child components
}
bindEvents() {
// Override in child components
}
onAttributeChanged(name, oldValue, newValue) {
// Override in child components if needed
}
// Utility methods
$(selector) {
return this.querySelector(selector);
}
$$(selector) {
return this.querySelectorAll(selector);
}
emit(eventName, detail = {}) {
const event = new CustomEvent(eventName, {
detail,
bubbles: true,
composed: true
});
this.dispatchEvent(event);
}
// HTML template helpers
html(strings, ...values) {
return strings.reduce((result, string, i) => {
const value = values[i] !== undefined ? values[i] : '';
return result + string + value;
}, '');
}
css(styles) {
if (typeof styles === 'string') {
return `<style>${styles}</style>`;
}
if (typeof styles === 'object') {
const cssText = Object.entries(styles)
.map(([key, value]) => `${key}: ${value};`)
.join(' ');
return cssText;
}
return '';
}
}

View File

@ -0,0 +1,417 @@
<template>
<div v-if="showPrompt" class="pwa-install-prompt">
<q-banner class="install-banner" rounded>
<template v-slot:avatar>
<q-icon name="get_app" color="primary" size="md" />
</template>
<div class="banner-content">
<div class="banner-title">安裝 Drama Ling 應用程式</div>
<div class="banner-description">
在桌面安裝應用程式享受更好的學習體驗
</div>
<ul class="features-list">
<li>離線學習功能</li>
<li>快速啟動和存取</li>
<li>推播通知提醒</li>
<li>全螢幕沉浸體驗</li>
</ul>
</div>
<template v-slot:action>
<div class="banner-actions">
<q-btn
color="primary"
label="立即安裝"
@click="installApp"
:loading="isInstalling"
no-caps
/>
<q-btn
flat
label="稍後提醒"
@click="postponeInstall"
no-caps
class="q-ml-sm"
/>
<q-btn
flat
icon="close"
@click="dismissPermanently"
size="sm"
class="q-ml-sm"
/>
</div>
</template>
</q-banner>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useQuasar } from 'quasar'
const $q = useQuasar()
//
const showPrompt = ref(false)
const isInstalling = ref(false)
const deferredPrompt = ref<any>(null)
// PWA
const isStandalone = ref(false)
const isInstalled = ref(false)
// PWA
const checkPWAStatus = () => {
// ()
isStandalone.value = window.matchMedia('(display-mode: standalone)').matches ||
('standalone' in window.navigator && (window.navigator as any).standalone === true)
//
const userPreference = localStorage.getItem('pwa-install-preference')
const lastPromptTime = localStorage.getItem('pwa-last-prompt')
//
if (isStandalone.value || userPreference === 'never') {
isInstalled.value = true
return false
}
// (7)
if (userPreference === 'later' && lastPromptTime) {
const daysSincePrompt = (Date.now() - parseInt(lastPromptTime)) / (1000 * 60 * 60 * 24)
if (daysSincePrompt < 7) {
return false
}
}
return true
}
// beforeinstallprompt
const handleBeforeInstallPrompt = (e: Event) => {
//
e.preventDefault()
// 使
deferredPrompt.value = e
//
if (checkPWAStatus()) {
showPrompt.value = true
}
}
//
const installApp = async () => {
if (!deferredPrompt.value) {
// beforeinstallprompt
showManualInstallInstructions()
return
}
isInstalling.value = true
try {
//
const result = await deferredPrompt.value.prompt()
//
const choiceResult = await deferredPrompt.value.userChoice
if (choiceResult.outcome === 'accepted') {
//
$q.notify({
type: 'positive',
message: '應用程式安裝成功!',
icon: 'check_circle',
timeout: 3000
})
//
localStorage.setItem('pwa-install-preference', 'installed')
localStorage.setItem('pwa-install-time', Date.now().toString())
showPrompt.value = false
} else {
//
$q.notify({
type: 'info',
message: '你可以稍後從瀏覽器選單安裝應用程式',
icon: 'info',
timeout: 3000
})
}
// deferredPrompt
deferredPrompt.value = null
} catch (error) {
console.error('安裝失敗:', error)
$q.notify({
type: 'negative',
message: '安裝過程發生錯誤',
icon: 'error',
timeout: 3000
})
} finally {
isInstalling.value = false
}
}
//
const postponeInstall = () => {
localStorage.setItem('pwa-install-preference', 'later')
localStorage.setItem('pwa-last-prompt', Date.now().toString())
showPrompt.value = false
$q.notify({
type: 'info',
message: '我們會在 7 天後再次提醒你',
icon: 'schedule',
timeout: 3000
})
}
//
const dismissPermanently = () => {
localStorage.setItem('pwa-install-preference', 'never')
showPrompt.value = false
$q.notify({
type: 'info',
message: '你可以隨時從設定中啟用安裝提示',
icon: 'settings',
timeout: 3000
})
}
//
const showManualInstallInstructions = () => {
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent)
const isAndroid = /Android/.test(navigator.userAgent)
let instructions = ''
if (isIOS) {
instructions = '點擊 Safari 底部的分享按鈕,然後選擇「加入主畫面」'
} else if (isAndroid) {
instructions = '點擊瀏覽器選單中的「安裝應用程式」或「加到主畫面」'
} else {
instructions = '點擊網址列右側的安裝圖示,或從瀏覽器選單選擇「安裝」'
}
$q.dialog({
title: '手動安裝應用程式',
message: instructions,
html: true,
ok: {
label: '我知道了',
color: 'primary'
}
})
}
//
const handleAppInstalled = () => {
console.log('PWA 安裝成功')
$q.notify({
type: 'positive',
message: '歡迎使用 Drama Ling 應用程式!',
icon: 'celebration',
timeout: 5000,
actions: [{
label: '開始學習',
color: 'white',
handler: () => {
//
}
}]
})
//
localStorage.setItem('pwa-install-preference', 'installed')
localStorage.setItem('pwa-install-time', Date.now().toString())
showPrompt.value = false
isInstalled.value = true
}
// ()
const resetInstallPrompt = () => {
localStorage.removeItem('pwa-install-preference')
localStorage.removeItem('pwa-last-prompt')
localStorage.removeItem('pwa-install-time')
if (checkPWAStatus() && deferredPrompt.value) {
showPrompt.value = true
}
}
//
onMounted(() => {
// PWA
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
window.addEventListener('appinstalled', handleAppInstalled)
//
setTimeout(() => {
if (checkPWAStatus()) {
// beforeinstallprompt
if (!deferredPrompt.value) {
const visitCount = parseInt(localStorage.getItem('visit-count') || '0')
if (visitCount >= 3) { // 3
showPrompt.value = true
}
}
}
}, 3000) // 3
//
const visitCount = parseInt(localStorage.getItem('visit-count') || '0')
localStorage.setItem('visit-count', (visitCount + 1).toString())
})
onUnmounted(() => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
window.removeEventListener('appinstalled', handleAppInstalled)
})
// 使
defineExpose({
installApp,
resetInstallPrompt,
isInstalled,
isStandalone
})
</script>
<style lang="scss" scoped>
.pwa-install-prompt {
position: fixed;
bottom: $space-4;
left: $space-4;
right: $space-4;
z-index: 1000;
max-width: 600px;
margin: 0 auto;
@media (max-width: 768px) {
bottom: $space-3;
left: $space-3;
right: $space-3;
}
}
.install-banner {
background: linear-gradient(135deg, $primary-teal 0%, $secondary-purple 100%);
color: white;
border-radius: $radius-xl;
box-shadow: $shadow-xl;
backdrop-filter: blur(10px);
:deep(.q-banner__content) {
padding: $space-4;
}
:deep(.q-banner__avatar) {
margin-right: $space-4;
align-self: flex-start;
margin-top: $space-1;
}
}
.banner-content {
flex: 1;
}
.banner-title {
font-size: $text-lg;
font-weight: 700;
margin-bottom: $space-2;
color: white;
}
.banner-description {
font-size: $text-base;
color: rgba(white, 0.9);
margin-bottom: $space-3;
}
.features-list {
margin: 0;
padding-left: $space-5;
li {
font-size: $text-sm;
color: rgba(white, 0.8);
margin-bottom: $space-1;
&:last-child {
margin-bottom: 0;
}
}
}
.banner-actions {
display: flex;
flex-direction: column;
gap: $space-2;
align-items: stretch;
margin-top: $space-4;
@media (min-width: 600px) {
flex-direction: row;
align-items: center;
margin-top: 0;
}
.q-btn {
min-width: 100px;
@media (max-width: 599px) {
width: 100%;
}
}
}
//
.pwa-install-prompt {
animation: slideUp 0.5s ease-out;
}
@keyframes slideUp {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
// 調
@media (max-width: 480px) {
.banner-content {
.banner-title {
font-size: $text-base;
}
.banner-description {
font-size: $text-sm;
}
.features-list {
padding-left: $space-4;
li {
font-size: $text-xs;
}
}
}
}
</style>

View File

@ -0,0 +1,109 @@
<template>
<button
:class="buttonClass"
:disabled="disabled"
:type="type || 'button'"
@click="$emit('click')"
>
<slot>{{ label || 'Button' }}</slot>
</button>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
variant?: 'primary' | 'secondary' | 'outline'
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
loading?: boolean
label?: string
type?: 'button' | 'submit' | 'reset'
}
const props = withDefaults(defineProps<Props>(), {
variant: 'primary',
size: 'md',
disabled: false,
loading: false,
type: 'button'
})
defineEmits<{
click: []
}>()
const buttonClass = computed(() => [
'base-button',
`base-button--${props.variant}`,
`base-button--${props.size}`,
{
'base-button--disabled': props.disabled,
'base-button--loading': props.loading
}
])
</script>
<style scoped>
.base-button {
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
font-family: inherit;
}
.base-button--disabled {
opacity: 0.5;
cursor: not-allowed;
}
.base-button--sm {
padding: 8px 12px;
font-size: 0.875rem;
}
.base-button--md {
padding: 10px 16px;
font-size: 1rem;
}
.base-button--lg {
padding: 12px 20px;
font-size: 1.125rem;
}
.base-button--primary {
background: #00E5CC;
color: white;
}
.base-button--primary:hover:not(.base-button--disabled) {
background: #00C5B0;
}
.base-button--secondary {
background: #6C63FF;
color: white;
}
.base-button--secondary:hover:not(.base-button--disabled) {
background: #5A52E8;
}
.base-button--outline {
background: transparent;
color: #00E5CC;
border: 2px solid #00E5CC;
}
.base-button--outline:hover:not(.base-button--disabled) {
background: #00E5CC;
color: white;
}
</style>

View File

@ -0,0 +1,60 @@
<template>
<div :class="cardClass">
<slot />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
hoverable?: boolean
elevated?: boolean
padding?: 'sm' | 'md' | 'lg'
}
const props = withDefaults(defineProps<Props>(), {
hoverable: false,
elevated: false,
padding: 'md'
})
const cardClass = computed(() => [
'base-card',
`base-card--${props.padding}`,
{
'base-card--hoverable': props.hoverable,
'base-card--elevated': props.elevated
}
])
</script>
<style scoped>
.base-card {
background: white;
border-radius: 12px;
border: 1px solid #E5E7EB;
transition: all 0.2s ease;
}
.base-card--sm {
padding: 12px;
}
.base-card--md {
padding: 20px;
}
.base-card--lg {
padding: 32px;
}
.base-card--hoverable:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
}
.base-card--elevated {
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
</style>

View File

@ -0,0 +1,155 @@
<template>
<div class="base-input">
<label v-if="label" class="base-input__label" :for="inputId">
{{ label }}
<span v-if="required" class="text-error">*</span>
</label>
<div class="base-input__wrapper">
<input
:id="inputId"
:value="modelValue"
:type="type"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:class="inputClass"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
@blur="$emit('blur')"
@focus="$emit('focus')"
/>
</div>
<div v-if="error" class="base-input__error">
{{ error }}
</div>
<div v-if="hint && !error" class="base-input__hint">
{{ hint }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed, useId } from 'vue'
interface Props {
modelValue?: string
label?: string
type?: 'text' | 'email' | 'password' | 'number' | 'tel' | 'url'
placeholder?: string
disabled?: boolean
readonly?: boolean
required?: boolean
error?: string
hint?: string
size?: 'sm' | 'md' | 'lg'
}
const props = withDefaults(defineProps<Props>(), {
type: 'text',
disabled: false,
readonly: false,
required: false,
size: 'md'
})
defineEmits<{
'update:modelValue': [value: string]
blur: []
focus: []
}>()
const inputId = useId()
const inputClass = computed(() => [
'base-input__field',
`base-input__field--${props.size}`,
{
'base-input__field--error': props.error,
'base-input__field--disabled': props.disabled,
'base-input__field--readonly': props.readonly
}
])
</script>
<style scoped>
.base-input {
display: flex;
flex-direction: column;
gap: 6px;
}
.base-input__label {
font-size: 0.875rem;
font-weight: 500;
color: #374151;
}
.base-input__wrapper {
position: relative;
}
.base-input__field {
width: 100%;
border: 1px solid #D1D5DB;
border-radius: 6px;
font-size: 1rem;
transition: all 0.2s ease;
font-family: inherit;
}
.base-input__field:focus {
outline: none;
border-color: #00E5CC;
box-shadow: 0 0 0 3px rgba(0, 229, 204, 0.1);
}
.base-input__field--sm {
padding: 8px 12px;
font-size: 0.875rem;
}
.base-input__field--md {
padding: 10px 14px;
font-size: 1rem;
}
.base-input__field--lg {
padding: 12px 16px;
font-size: 1.125rem;
}
.base-input__field--error {
border-color: #EF4444;
}
.base-input__field--error:focus {
border-color: #EF4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}
.base-input__field--disabled {
background-color: #F3F4F6;
color: #9CA3AF;
cursor: not-allowed;
}
.base-input__field--readonly {
background-color: #F9FAFB;
}
.base-input__error {
font-size: 0.875rem;
color: #EF4444;
}
.base-input__hint {
font-size: 0.875rem;
color: #6B7280;
}
.text-error {
color: #EF4444;
}
</style>

View File

@ -0,0 +1,217 @@
<template>
<Teleport to="body">
<Transition name="modal" appear>
<div
v-if="modelValue"
class="base-modal"
@click="handleBackdropClick"
@keydown.esc="handleEscKey"
>
<div
class="base-modal__content"
:class="contentClass"
role="dialog"
>
<!-- 關閉按鈕 -->
<button
v-if="showClose"
class="base-modal__close"
@click="$emit('update:modelValue', false)"
aria-label="關閉"
>
×
</button>
<!-- 標題 -->
<header v-if="title || $slots.header" class="base-modal__header">
<slot name="header">
<h2 class="base-modal__title">{{ title }}</h2>
</slot>
</header>
<!-- 內容 -->
<div class="base-modal__body">
<slot />
</div>
<!-- 底部按鈕 -->
<footer v-if="$slots.footer" class="base-modal__footer">
<slot name="footer" />
</footer>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted } from 'vue'
interface Props {
modelValue: boolean
title?: string
size?: 'sm' | 'md' | 'lg' | 'xl'
persistent?: boolean
showClose?: boolean
}
const props = withDefaults(defineProps<Props>(), {
size: 'md',
persistent: false,
showClose: true
})
defineEmits<{
'update:modelValue': [value: boolean]
}>()
const contentClass = computed(() => [
'base-modal__content',
`base-modal__content--${props.size}`
])
const handleBackdropClick = (event: MouseEvent) => {
if (!props.persistent && event.target === event.currentTarget) {
emit('update:modelValue', false)
}
}
const handleEscKey = (event: KeyboardEvent) => {
if (!props.persistent && event.key === 'Escape') {
emit('update:modelValue', false)
}
}
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
//
let originalBodyOverflow = ''
onMounted(() => {
if (props.modelValue) {
originalBodyOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
}
})
onUnmounted(() => {
document.body.style.overflow = originalBodyOverflow
})
</script>
<style scoped>
.base-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}
.base-modal__content {
background: white;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
position: relative;
max-height: 90vh;
overflow-y: auto;
}
.base-modal__content--sm {
width: 100%;
max-width: 400px;
}
.base-modal__content--md {
width: 100%;
max-width: 600px;
}
.base-modal__content--lg {
width: 100%;
max-width: 800px;
}
.base-modal__content--xl {
width: 100%;
max-width: 1200px;
}
.base-modal__close {
position: absolute;
top: 16px;
right: 16px;
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #6B7280;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.2s ease;
}
.base-modal__close:hover {
background: #F3F4F6;
color: #374151;
}
.base-modal__header {
padding: 24px 24px 0 24px;
border-bottom: 1px solid #E5E7EB;
padding-bottom: 16px;
margin-bottom: 20px;
}
.base-modal__title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #111827;
}
.base-modal__body {
padding: 0 24px 24px 24px;
}
.base-modal__footer {
padding: 16px 24px 24px 24px;
border-top: 1px solid #E5E7EB;
display: flex;
justify-content: flex-end;
gap: 12px;
}
/* 轉場動畫 */
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-active .base-modal__content,
.modal-leave-active .base-modal__content {
transition: transform 0.3s ease;
}
.modal-enter-from .base-modal__content,
.modal-leave-to .base-modal__content {
transform: scale(0.9) translateY(20px);
}
</style>

View File

@ -0,0 +1,292 @@
<template>
<div class="vocabulary-section">
<div class="vocabulary-card">
<!-- 詞彙主要顯示區 -->
<div class="vocabulary-word">{{ word.text }}</div>
<div class="vocabulary-phonetic">{{ word.phonetic }}</div>
<div class="vocabulary-definition">{{ word.definition }}</div>
<!-- 例句區域 -->
<div v-if="word.example" class="vocabulary-example">
{{ word.example }}
</div>
<!-- 控制按鈕區域 -->
<div class="vocabulary-controls">
<button
class="control-btn"
@click="playAudio"
:disabled="audioLoading"
:title="audioLoading ? '播放中...' : '播放發音 (Space)'"
>
<QIcon :name="audioLoading ? 'hourglass_empty' : 'volume_up'" />
播放發音
</button>
<button
class="control-btn"
@click="playSlowAudio"
:disabled="audioLoading"
:title="audioLoading ? '播放中...' : '慢速播放 (Shift+Space)'"
>
<QIcon :name="audioLoading ? 'hourglass_empty' : 'slow_motion_video'" />
慢速播放
</button>
<button
class="control-btn primary"
@click="startPractice"
:title="'開始練習 (Enter)'"
>
<QIcon name="play_arrow" />
開始練習
</button>
</div>
<!-- 難度評估按鈕 -->
<div class="difficulty-buttons">
<button
class="difficulty-btn easy"
@click="$emit('difficulty', 'easy')"
:title="'標記為簡單'"
>
簡單
</button>
<button
class="difficulty-btn"
@click="$emit('difficulty', 'normal')"
:title="'標記為一般'"
>
一般
</button>
<button
class="difficulty-btn hard"
@click="$emit('difficulty', 'hard')"
:title="'標記為困難'"
>
困難
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { useAudio } from '@/composables/useAudio'
// ( development-standards.md)
interface VocabularyWord {
id: string
text: string
phonetic: string
definition: string
example?: string
audio_url?: string
difficulty_level?: number
}
interface Props {
word: VocabularyWord
disabled?: boolean
}
interface Emits {
'audio-play': [wordId: string]
'practice-start': [wordId: string]
'difficulty': [level: 'easy' | 'normal' | 'hard']
}
// Props Emits ( development-standards.md)
const props = withDefaults(defineProps<Props>(), {
disabled: false
})
const emit = defineEmits<Emits>()
// ( vue-frontend-architecture.md)
const { quickPlay, isLoading: audioLoading } = useAudio()
//
const playAudio = async () => {
if (props.word.audio_url) {
await quickPlay(props.word.audio_url)
emit('audio-play', props.word.id)
}
}
const playSlowAudio = async () => {
if (props.word.audio_url) {
await quickPlay(props.word.audio_url, { playbackRate: 0.75 })
emit('audio-play', props.word.id)
}
}
const startPractice = () => {
emit('practice-start', props.word.id)
}
// ( function-specs Web)
const handleKeyboard = (event: KeyboardEvent) => {
if (props.disabled) return
switch (event.code) {
case 'Space':
event.preventDefault()
if (event.shiftKey) {
playSlowAudio()
} else {
playAudio()
}
break
case 'Enter':
event.preventDefault()
startPractice()
break
}
}
//
onMounted(() => {
document.addEventListener('keydown', handleKeyboard)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeyboard)
})
</script>
<style lang="scss" scoped>
// vocabulary.html
.vocabulary-section {
background: var(--bg-card, #{$card-background});
border: 1px solid var(--divider, #{$divider});
border-radius: var(--radius-xl, #{$radius-xl});
padding: var(--space-8, #{$space-8});
margin-bottom: var(--space-8, #{$space-8});
text-align: center;
}
.vocabulary-card {
max-width: 600px;
margin: 0 auto;
padding: var(--space-8, #{$space-8});
position: relative;
}
.vocabulary-word {
font-size: var(--text-5xl, #{$text-4xl}); // 使
font-weight: 700;
color: var(--text-primary, #{$text-primary});
margin-bottom: var(--space-4, #{$space-4});
}
.vocabulary-phonetic {
font-size: var(--text-xl, #{$text-xl});
color: var(--text-secondary, #{$text-secondary});
margin-bottom: var(--space-6, #{$space-6});
}
.vocabulary-definition {
font-size: var(--text-lg, #{$text-lg});
color: var(--text-primary, #{$text-primary});
margin-bottom: var(--space-6, #{$space-6});
line-height: 1.6;
}
.vocabulary-example {
background: var(--bg-secondary, #{$background-secondary});
padding: var(--space-4, #{$space-4});
border-radius: var(--radius-lg, #{$radius-lg});
margin-bottom: var(--space-6, #{$space-6});
font-style: italic;
color: var(--text-secondary, #{$text-secondary});
}
.vocabulary-controls {
display: flex;
justify-content: center;
gap: var(--space-4, #{$space-4});
margin-top: var(--space-8, #{$space-8});
//
@include respond-to(sm) {
flex-direction: column;
align-items: center;
}
}
.control-btn {
padding: var(--space-3, #{$space-3}) var(--space-6, #{$space-6});
border: 2px solid var(--divider, #{$divider});
border-radius: var(--radius-lg, #{$radius-lg});
background: var(--bg-card, #{$card-background});
color: var(--text-primary, #{$text-primary});
font-size: var(--text-base, #{$text-base});
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: var(--space-2, #{$space-2});
&:hover {
border-color: var(--primary-teal, #{$primary-teal});
background: rgba(0, 229, 204, 0.1);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&.primary {
background: var(--primary-teal, #{$primary-teal});
border-color: var(--primary-teal, #{$primary-teal});
color: white;
&:hover {
background: #00b8a0;
}
}
}
.difficulty-buttons {
display: flex;
justify-content: center;
gap: var(--space-3, #{$space-3});
margin-top: var(--space-6, #{$space-6});
}
.difficulty-btn {
padding: var(--space-2, #{$space-2}) var(--space-4, #{$space-4});
border: 1px solid var(--divider, #{$divider});
border-radius: var(--radius-md, #{$radius-md});
background: var(--bg-card, #{$card-background});
color: var(--text-secondary, #{$text-secondary});
font-size: var(--text-sm, #{$text-sm});
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: var(--bg-secondary, #{$background-secondary});
}
&.easy {
border-color: var(--success-green, #{$success-green});
color: var(--success-green, #{$success-green});
}
&.hard {
border-color: var(--error-red, #{$error-red});
color: var(--error-red, #{$error-red});
}
}
//
.control-btn:focus,
.difficulty-btn:focus {
outline: 2px solid var(--primary-teal, #{$primary-teal});
outline-offset: 2px;
}
</style>

View File

@ -0,0 +1,515 @@
<template>
<div class="error-heatmap">
<div class="heatmap-container" ref="heatmapContainer">
<div class="heatmap-grid">
<!-- 標題行 -->
<div class="heatmap-header">
<div class="header-cell empty"></div>
<div
v-for="errorType in errorTypes"
:key="errorType"
class="header-cell"
>
{{ errorType }}
</div>
</div>
<!-- 數據行 -->
<div
v-for="category in categories"
:key="category"
class="heatmap-row"
>
<div class="row-header">{{ category }}</div>
<div
v-for="errorType in errorTypes"
:key="`${category}-${errorType}`"
class="heatmap-cell"
:class="getCellClass(category, errorType)"
:style="getCellStyle(category, errorType)"
@mouseenter="showTooltip($event, category, errorType)"
@mouseleave="hideTooltip"
>
<span class="cell-value">
{{ getCellValue(category, errorType) }}
</span>
</div>
</div>
</div>
<!-- 圖例 -->
<div class="heatmap-legend">
<div class="legend-title">{{ getLegendTitle() }}</div>
<div class="legend-scale">
<div class="scale-labels">
<span class="scale-min">{{ minValue }}</span>
<span class="scale-max">{{ maxValue }}</span>
</div>
<div class="scale-bar">
<div
v-for="i in 10"
:key="i"
class="scale-segment"
:style="{
backgroundColor: getColorForValue(minValue + (maxValue - minValue) * (i - 1) / 9)
}"
/>
</div>
</div>
</div>
</div>
<!-- 工具提示 -->
<div
v-if="tooltip.visible"
class="heatmap-tooltip"
:style="{
left: tooltip.x + 'px',
top: tooltip.y + 'px'
}"
>
<div class="tooltip-header">
<strong>{{ tooltip.category }} - {{ tooltip.errorType }}</strong>
</div>
<div class="tooltip-content">
<div class="tooltip-row">
<span class="tooltip-label">錯誤次數:</span>
<span class="tooltip-value">{{ tooltip.count }}</span>
</div>
<div class="tooltip-row">
<span class="tooltip-label">正確率:</span>
<span class="tooltip-value">{{ tooltip.accuracy }}%</span>
</div>
<div class="tooltip-row">
<span class="tooltip-label">平均反應時間:</span>
<span class="tooltip-value">{{ tooltip.responseTime }}ms</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
interface ErrorData {
category: string
type: string
count: number
accuracy: number
avgResponseTime?: number
}
interface Props {
data: ErrorData[]
metric: 'accuracy' | 'response_time' | 'error_count'
}
const props = withDefaults(defineProps<Props>(), {
metric: 'accuracy'
})
const heatmapContainer = ref<HTMLElement>()
//
const tooltip = ref({
visible: false,
x: 0,
y: 0,
category: '',
errorType: '',
count: 0,
accuracy: 0,
responseTime: 0
})
//
const categories = computed(() => {
return [...new Set(props.data.map(item => item.category))]
})
const errorTypes = computed(() => {
return [...new Set(props.data.map(item => item.type))]
})
const dataMap = computed(() => {
const map = new Map<string, ErrorData>()
props.data.forEach(item => {
const key = `${item.category}-${item.type}`
map.set(key, item)
})
return map
})
const minValue = computed(() => {
const values = getMetricValues()
return Math.min(...values)
})
const maxValue = computed(() => {
const values = getMetricValues()
return Math.max(...values)
})
//
const getMetricValues = (): number[] => {
return props.data.map(item => {
switch (props.metric) {
case 'accuracy':
return item.accuracy
case 'response_time':
return item.avgResponseTime || 0
case 'error_count':
return item.count
default:
return item.accuracy
}
})
}
const getCellValue = (category: string, errorType: string): string => {
const key = `${category}-${errorType}`
const item = dataMap.value.get(key)
if (!item) return '-'
switch (props.metric) {
case 'accuracy':
return `${item.accuracy}%`
case 'response_time':
return `${item.avgResponseTime || 0}ms`
case 'error_count':
return item.count.toString()
default:
return `${item.accuracy}%`
}
}
const getCellClass = (category: string, errorType: string): string => {
const key = `${category}-${errorType}`
const item = dataMap.value.get(key)
if (!item) return 'cell-empty'
const value = getItemMetricValue(item)
const intensity = getIntensity(value)
return `cell-intensity-${intensity}`
}
const getCellStyle = (category: string, errorType: string): Record<string, string> => {
const key = `${category}-${errorType}`
const item = dataMap.value.get(key)
if (!item) return {}
const value = getItemMetricValue(item)
const backgroundColor = getColorForValue(value)
return {
backgroundColor,
color: getTextColor(backgroundColor)
}
}
const getItemMetricValue = (item: ErrorData): number => {
switch (props.metric) {
case 'accuracy':
return item.accuracy
case 'response_time':
return item.avgResponseTime || 0
case 'error_count':
return item.count
default:
return item.accuracy
}
}
const getIntensity = (value: number): number => {
const range = maxValue.value - minValue.value
if (range === 0) return 5
const normalizedValue = (value - minValue.value) / range
return Math.ceil(normalizedValue * 10)
}
const getColorForValue = (value: number): string => {
const range = maxValue.value - minValue.value
if (range === 0) return '#f0f0f0'
const normalizedValue = (value - minValue.value) / range
//
if (props.metric === 'accuracy') {
//
const red = Math.round(255 * (1 - normalizedValue))
const green = Math.round(255 * normalizedValue)
return `rgb(${red}, ${green}, 50)`
} else {
// //
const red = Math.round(255 * normalizedValue)
const green = Math.round(255 * (1 - normalizedValue))
return `rgb(${red}, ${green}, 50)`
}
}
const getTextColor = (backgroundColor: string): string => {
// 使
const rgb = backgroundColor.match(/\d+/g)
if (!rgb) return '#000'
const [r, g, b] = rgb.map(Number)
const brightness = (r * 299 + g * 587 + b * 114) / 1000
return brightness > 150 ? '#000' : '#fff'
}
const getLegendTitle = (): string => {
const titles = {
accuracy: '正確率 (%)',
response_time: '反應時間 (ms)',
error_count: '錯誤次數'
}
return titles[props.metric]
}
const showTooltip = (event: MouseEvent, category: string, errorType: string) => {
const key = `${category}-${errorType}`
const item = dataMap.value.get(key)
if (!item) return
const rect = heatmapContainer.value?.getBoundingClientRect()
if (!rect) return
tooltip.value = {
visible: true,
x: event.clientX - rect.left + 10,
y: event.clientY - rect.top - 10,
category,
errorType,
count: item.count,
accuracy: item.accuracy,
responseTime: item.avgResponseTime || 0
}
}
const hideTooltip = () => {
tooltip.value.visible = false
}
onMounted(() => {
//
})
</script>
<style lang="scss" scoped>
.error-heatmap {
position: relative;
width: 100%;
height: 100%;
.heatmap-container {
display: flex;
flex-direction: column;
height: 100%;
.heatmap-grid {
flex: 1;
display: flex;
flex-direction: column;
overflow: auto;
.heatmap-header {
display: flex;
background: $bg-secondary;
border-radius: $radius-md $radius-md 0 0;
.header-cell {
min-width: 80px;
padding: $space-2;
text-align: center;
font-weight: 600;
color: $text-primary;
border-right: 1px solid $divider;
font-size: $text-sm;
&.empty {
min-width: 120px;
background: transparent;
}
&:last-child {
border-right: none;
}
}
}
.heatmap-row {
display: flex;
border-bottom: 1px solid $divider;
&:last-child {
border-bottom: none;
}
.row-header {
min-width: 120px;
padding: $space-2;
background: $bg-secondary;
font-weight: 600;
color: $text-primary;
display: flex;
align-items: center;
border-right: 1px solid $divider;
font-size: $text-sm;
}
.heatmap-cell {
min-width: 80px;
min-height: 40px;
padding: $space-2;
display: flex;
align-items: center;
justify-content: center;
border-right: 1px solid $divider;
cursor: pointer;
transition: all 0.2s ease;
&:last-child {
border-right: none;
}
&:hover {
transform: scale(1.05);
z-index: 10;
box-shadow: $shadow-md;
}
&.cell-empty {
background: $bg-card;
color: $text-disabled;
}
.cell-value {
font-size: $text-xs;
font-weight: 600;
}
}
}
}
.heatmap-legend {
margin-top: $space-4;
padding: $space-3;
background: $bg-secondary;
border-radius: $radius-md;
.legend-title {
font-size: $text-sm;
font-weight: 600;
color: $text-primary;
margin-bottom: $space-2;
text-align: center;
}
.legend-scale {
.scale-labels {
display: flex;
justify-content: space-between;
margin-bottom: $space-1;
font-size: $text-xs;
color: $text-secondary;
}
.scale-bar {
display: flex;
height: 12px;
border-radius: $radius-sm;
overflow: hidden;
border: 1px solid $divider;
.scale-segment {
flex: 1;
}
}
}
}
}
.heatmap-tooltip {
position: absolute;
z-index: 1000;
background: $bg-card;
border: 1px solid $divider;
border-radius: $radius-md;
box-shadow: $shadow-lg;
padding: $space-3;
max-width: 250px;
pointer-events: none;
.tooltip-header {
font-size: $text-sm;
margin-bottom: $space-2;
color: $text-primary;
}
.tooltip-content {
.tooltip-row {
display: flex;
justify-content: space-between;
margin-bottom: $space-1;
font-size: $text-xs;
&:last-child {
margin-bottom: 0;
}
.tooltip-label {
color: $text-secondary;
}
.tooltip-value {
color: $text-primary;
font-weight: 600;
}
}
}
}
}
//
@media (max-width: 768px) {
.error-heatmap {
.heatmap-container .heatmap-grid {
.heatmap-header .header-cell,
.heatmap-row .heatmap-cell {
min-width: 60px;
padding: $space-1;
font-size: $text-xs;
}
.heatmap-row .row-header {
min-width: 100px;
font-size: $text-xs;
}
}
}
}
//
.heatmap-cell {
animation: fadeIn 0.4s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
</style>

View File

@ -0,0 +1,223 @@
<template>
<div class="stat-card">
<div class="stat-content">
<!-- 圖標和趨勢 -->
<div class="stat-header">
<div class="stat-icon-wrapper" :class="`bg-${color}`">
<q-icon :name="icon" class="stat-icon" />
</div>
<div class="stat-trend" v-if="trend && change">
<q-icon
:name="trend === 'up' ? 'trending_up' : 'trending_down'"
:color="trend === 'up' ? 'positive' : 'negative'"
size="sm"
/>
<span
class="trend-text"
:class="trend === 'up' ? 'text-positive' : 'text-negative'"
>
{{ change }}
</span>
</div>
</div>
<!-- 主要數值 -->
<div class="stat-value">
<span class="value-number">{{ value }}</span>
</div>
<!-- 標題和副標題 -->
<div class="stat-info">
<h3 class="stat-title">{{ title }}</h3>
<p class="stat-subtitle" v-if="subtitle">{{ subtitle }}</p>
</div>
</div>
<!-- 互動效果遮罩 -->
<div class="stat-overlay"></div>
</div>
</template>
<script setup lang="ts">
interface Props {
title: string
value: string | number
subtitle?: string
icon: string
color: string
trend?: 'up' | 'down'
change?: string
}
const props = withDefaults(defineProps<Props>(), {
color: 'primary'
})
</script>
<style lang="scss" scoped>
.stat-card {
position: relative;
background: $bg-card;
border-radius: $radius-xl;
padding: $space-6;
box-shadow: $shadow-sm;
border: 1px solid $divider;
transition: all 0.3s ease;
cursor: pointer;
overflow: hidden;
&:hover {
transform: translateY(-2px);
box-shadow: $shadow-lg;
.stat-overlay {
opacity: 0.1;
}
}
.stat-content {
position: relative;
z-index: 2;
.stat-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: $space-4;
.stat-icon-wrapper {
width: 48px;
height: 48px;
border-radius: $radius-lg;
display: flex;
align-items: center;
justify-content: center;
.stat-icon {
font-size: 24px;
color: white;
}
&.bg-primary {
background: linear-gradient(135deg, $primary-teal 0%, lighten($primary-teal, 10%) 100%);
}
&.bg-positive {
background: linear-gradient(135deg, $positive 0%, lighten($positive, 10%) 100%);
}
&.bg-info {
background: linear-gradient(135deg, $info 0%, lighten($info, 10%) 100%);
}
&.bg-warning {
background: linear-gradient(135deg, $warning 0%, lighten($warning, 10%) 100%);
}
&.bg-negative {
background: linear-gradient(135deg, $negative 0%, lighten($negative, 10%) 100%);
}
}
.stat-trend {
display: flex;
align-items: center;
gap: $space-1;
padding: $space-1 $space-2;
border-radius: $radius-md;
background: rgba($bg-secondary, 0.5);
.trend-text {
font-size: $text-sm;
font-weight: 600;
}
}
}
.stat-value {
margin-bottom: $space-3;
.value-number {
font-size: $text-3xl;
font-weight: 700;
color: $text-primary;
line-height: 1;
}
}
.stat-info {
.stat-title {
font-size: $text-base;
font-weight: 600;
color: $text-primary;
margin: 0 0 $space-1;
line-height: 1.3;
}
.stat-subtitle {
font-size: $text-sm;
color: $text-secondary;
margin: 0;
line-height: 1.4;
}
}
}
.stat-overlay {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: linear-gradient(135deg, $primary-teal 0%, $secondary-purple 100%);
opacity: 0;
transition: opacity 0.3s ease;
z-index: 1;
}
}
//
@media (max-width: 768px) {
.stat-card {
padding: $space-4;
.stat-content {
.stat-header {
.stat-icon-wrapper {
width: 40px;
height: 40px;
.stat-icon {
font-size: 20px;
}
}
}
.stat-value .value-number {
font-size: $text-2xl;
}
.stat-info .stat-title {
font-size: $text-sm;
}
}
}
}
//
.stat-card {
animation: fadeInUp 0.6s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@ -0,0 +1,197 @@
<!-- 通用圖示組件 -->
<!-- 支援常用圖示基於CSS和Unicode字符 -->
<template>
<span
class="icon"
:class="[`icon-${name}`, size && `icon-${size}`]"
:style="{ color: color }"
>
{{ iconChar }}
</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
name: string
size?: 'sm' | 'md' | 'lg' | 'xl'
color?: string
}
const props = withDefaults(defineProps<Props>(), {
size: 'md'
})
//
const iconMap: Record<string, string> = {
//
'check': '✓',
'x': '✕',
'check-circle': '✅',
'x-circle': '❌',
'plus': '+',
'minus': '-',
'arrow-right': '→',
'arrow-left': '←',
'arrow-up': '↑',
'arrow-down': '↓',
'chevron-right': '',
'chevron-left': '',
'chevron-up': '',
'chevron-down': '',
//
'play': '▶',
'pause': '⏸',
'stop': '⏹',
'volume': '🔊',
'volume-mute': '🔇',
'skip-forward': '⏭',
'skip-back': '⏮',
'loading': '⟳',
//
'book': '📖',
'bookmark': '🔖',
'star': '⭐',
'heart': '❤️',
'trophy': '🏆',
'target': '🎯',
'lightbulb': '💡',
'brain': '🧠',
'graduation-cap': '🎓',
//
'clock': '🕐',
'timer': '⏰',
'calendar': '📅',
'history': '🕒',
//
'info': '',
'warning': '⚠',
'error': '⚠',
'success': '✅',
'question': '❓',
'exclamation': '❗',
//
'settings': '⚙',
'edit': '✎',
'delete': '🗑',
'copy': '📄',
'download': '⬇',
'upload': '⬆',
'search': '🔍',
'filter': '🔽',
'sort': '⇅',
//
'home': '🏠',
'menu': '☰',
'more': '⋯',
'close': '✕',
'back': '←',
'forward': '→',
//
'share': '📤',
'like': '👍',
'comment': '💬',
'user': '👤',
'users': '👥',
//
'file': '📄',
'folder': '📁',
'image': '🖼',
'video': '🎬',
'music': '🎵',
//
'choice': '☑',
'matching': '🔗',
'reorganize': '🔄',
'microphone': '🎤',
'speaker': '🔊',
'headphones': '🎧'
}
const iconChar = computed(() => {
return iconMap[props.name] || '?'
})
</script>
<style lang="scss" scoped>
.icon {
display: inline-block;
font-style: normal;
line-height: 1;
text-align: center;
vertical-align: middle;
//
&.icon-sm {
font-size: 0.875rem; // 14px
}
&.icon-md {
font-size: 1rem; // 16px
}
&.icon-lg {
font-size: 1.25rem; // 20px
}
&.icon-xl {
font-size: 1.5rem; // 24px
}
//
&.icon-loading {
animation: spin 1s linear infinite;
}
//
&.icon-heart {
color: #e74c3c;
}
&.icon-star {
color: #f1c40f;
}
&.icon-trophy {
color: #f39c12;
}
&.icon-success,
&.icon-check-circle {
color: #27ae60;
}
&.icon-error,
&.icon-x-circle {
color: #e74c3c;
}
&.icon-warning {
color: #f39c12;
}
&.icon-info {
color: #3498db;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View File

@ -0,0 +1,127 @@
<template>
<Teleport to="body">
<div v-if="currentModal" class="modal-container">
<Transition name="modal-backdrop">
<div
class="modal-backdrop"
@click="handleBackdropClick"
/>
</Transition>
<Transition name="modal-content">
<div class="modal-wrapper">
<component
:is="currentModal.component"
v-bind="currentModal.props"
@close="handleModalClose"
/>
</div>
</Transition>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, watch, onMounted, onUnmounted } from 'vue'
import { useUIStore } from '@/stores/ui'
const uiStore = useUIStore()
const currentModal = computed(() => uiStore.currentModal)
const handleBackdropClick = () => {
if (currentModal.value && !currentModal.value.persistent) {
uiStore.hideModal()
}
}
const handleModalClose = () => {
uiStore.hideModal()
}
const handleEscKey = (event: KeyboardEvent) => {
if (event.key === 'Escape' && currentModal.value && !currentModal.value.persistent) {
uiStore.hideModal()
}
}
//
let previousBodyStyle = ''
watch(currentModal, (modal) => {
if (modal) {
previousBodyStyle = document.body.style.overflow
document.body.style.overflow = 'hidden'
document.addEventListener('keydown', handleEscKey)
} else {
document.body.style.overflow = previousBodyStyle
document.removeEventListener('keydown', handleEscKey)
}
})
onMounted(() => {
if (currentModal.value) {
document.addEventListener('keydown', handleEscKey)
}
})
onUnmounted(() => {
document.removeEventListener('keydown', handleEscKey)
document.body.style.overflow = previousBodyStyle
})
</script>
<style scoped>
.modal-container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9998;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.modal-backdrop {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
}
.modal-wrapper {
position: relative;
z-index: 1;
max-width: 90vw;
max-height: 90vh;
overflow: auto;
}
/* 動畫效果 */
.modal-backdrop-enter-active,
.modal-backdrop-leave-active {
transition: opacity 0.3s ease;
}
.modal-backdrop-enter-from,
.modal-backdrop-leave-to {
opacity: 0;
}
.modal-content-enter-active,
.modal-content-leave-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.modal-content-enter-from,
.modal-content-leave-to {
opacity: 0;
transform: scale(0.9) translateY(20px);
}
</style>

View File

@ -0,0 +1,219 @@
<template>
<Teleport to="body">
<div class="toast-container">
<TransitionGroup name="toast" tag="div">
<div
v-for="toast in activeToasts"
:key="toast.id"
:class="toastClasses(toast)"
@click="handleToastClick(toast)"
>
<div class="toast__icon">
<QIcon :name="getToastIcon(toast.type)" />
</div>
<div class="toast__content">
<h4 class="toast__title">{{ toast.title }}</h4>
<p v-if="toast.message" class="toast__message">{{ toast.message }}</p>
</div>
<QBtn
flat
round
dense
icon="close"
size="sm"
class="toast__close"
@click.stop="uiStore.hideToast(toast.id)"
aria-label="關閉通知"
/>
</div>
</TransitionGroup>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useUIStore, type Toast } from '@/stores/ui'
const uiStore = useUIStore()
const activeToasts = computed(() => uiStore.activeToasts)
const toastClasses = (toast: Toast) => [
'toast',
`toast--${toast.type}`,
{
'toast--persistent': toast.persistent
}
]
const getToastIcon = (type: Toast['type']) => {
const icons = {
success: 'check_circle',
error: 'error',
warning: 'warning',
info: 'info'
}
return icons[type]
}
const handleToastClick = (toast: Toast) => {
if (!toast.persistent) {
uiStore.hideToast(toast.id)
}
}
</script>
<style scoped>
.toast-container {
position: fixed;
top: 24px;
right: 24px;
z-index: 9999;
max-width: 400px;
width: 100%;
pointer-events: none;
}
@media (max-width: 768px) {
.toast-container {
top: 16px;
right: 16px;
left: 16px;
max-width: none;
}
}
.toast {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px;
margin-bottom: 12px;
background: white;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
backdrop-filter: blur(8px);
border-left: 4px solid transparent;
cursor: pointer;
pointer-events: auto;
transition: all 0.3s ease;
}
.toast:hover {
transform: translateY(-2px);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
}
.toast__icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
}
.toast__icon .q-icon {
font-size: 20px;
}
.toast__content {
flex: 1;
min-width: 0;
}
.toast__title {
font-size: 14px;
font-weight: 600;
margin: 0 0 4px 0;
color: #2C3E50;
line-height: 1.4;
}
.toast__message {
font-size: 12px;
margin: 0;
color: #7F8C8D;
line-height: 1.4;
}
.toast__close {
flex-shrink: 0;
opacity: 0.6;
transition: opacity 0.2s ease;
}
.toast__close:hover {
opacity: 1;
}
/* 類型樣式 */
.toast--success {
border-left-color: #27AE60;
}
.toast--success .toast__icon {
background: rgba(39, 174, 96, 0.1);
color: #27AE60;
}
.toast--error {
border-left-color: #E74C3C;
}
.toast--error .toast__icon {
background: rgba(231, 76, 60, 0.1);
color: #E74C3C;
}
.toast--warning {
border-left-color: #F39C12;
}
.toast--warning .toast__icon {
background: rgba(243, 156, 18, 0.1);
color: #F39C12;
}
.toast--info {
border-left-color: #00E5CC;
}
.toast--info .toast__icon {
background: rgba(0, 229, 204, 0.1);
color: #00E5CC;
}
.toast--persistent {
cursor: default;
}
.toast--persistent:hover {
transform: none;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
}
/* 動畫效果 */
.toast-enter-active,
.toast-leave-active {
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.toast-enter-from {
opacity: 0;
transform: translateX(100%) scale(0.8);
}
.toast-leave-to {
opacity: 0;
transform: translateX(100%) scale(0.8);
}
.toast-move {
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
</style>

View File

@ -0,0 +1,270 @@
import { ref, onUnmounted } from 'vue'
import { useQuasar } from 'quasar'
export interface AudioOptions {
playbackRate?: number
volume?: number
loop?: boolean
preload?: boolean
}
export function useAudio() {
const $q = useQuasar()
// 狀態管理
const isPlaying = ref(false)
const isLoading = ref(false)
const duration = ref(0)
const currentTime = ref(0)
const volume = ref(1)
const playbackRate = ref(1)
const error = ref<string | null>(null)
// Web Audio API 支援
let audioContext: AudioContext | null = null
let currentAudioSource: AudioBufferSourceNode | null = null
let gainNode: GainNode | null = null
let audioBuffer: AudioBuffer | null = null
// HTML5 Audio fallback
let htmlAudio: HTMLAudioElement | null = null
// 初始化音頻上下文
const initAudioContext = async (): Promise<boolean> => {
if (audioContext) return true
try {
audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
gainNode = audioContext.createGain()
gainNode.connect(audioContext.destination)
return true
} catch (err) {
console.warn('Web Audio API 不支援,使用 HTML5 Audio fallback:', err)
return false
}
}
// 載入音頻文件
const loadAudio = async (url: string): Promise<boolean> => {
error.value = null
isLoading.value = true
try {
const useWebAudio = await initAudioContext()
if (useWebAudio && audioContext) {
// 使用 Web Audio API
const response = await fetch(url)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const arrayBuffer = await response.arrayBuffer()
audioBuffer = await audioContext.decodeAudioData(arrayBuffer)
duration.value = audioBuffer.duration
} else {
// 使用 HTML5 Audio fallback
htmlAudio = new Audio()
htmlAudio.preload = 'auto'
htmlAudio.src = url
return new Promise((resolve, reject) => {
if (!htmlAudio) {
reject(new Error('無法創建 Audio 元素'))
return
}
htmlAudio.onloadedmetadata = () => {
duration.value = htmlAudio!.duration
resolve(true)
}
htmlAudio.onerror = () => {
reject(new Error('音頻載入失敗'))
}
})
}
return true
} catch (err) {
error.value = err instanceof Error ? err.message : '載入音頻失敗'
console.error('載入音頻失敗:', err)
return false
} finally {
isLoading.value = false
}
}
// 播放音頻
const play = async (options?: AudioOptions) => {
if (isPlaying.value) {
stop()
}
try {
if (audioBuffer && audioContext && gainNode) {
// 使用 Web Audio API 播放
currentAudioSource = audioContext.createBufferSource()
currentAudioSource.buffer = audioBuffer
currentAudioSource.playbackRate.value = options?.playbackRate || playbackRate.value
gainNode.gain.value = options?.volume || volume.value
currentAudioSource.connect(gainNode)
currentAudioSource.start(0)
currentAudioSource.onended = () => {
isPlaying.value = false
currentTime.value = 0
}
} else if (htmlAudio) {
// 使用 HTML5 Audio 播放
htmlAudio.volume = options?.volume || volume.value
htmlAudio.playbackRate = options?.playbackRate || playbackRate.value
htmlAudio.loop = options?.loop || false
htmlAudio.ontimeupdate = () => {
currentTime.value = htmlAudio!.currentTime
}
htmlAudio.onended = () => {
isPlaying.value = false
currentTime.value = 0
}
await htmlAudio.play()
} else {
throw new Error('沒有可用的音頻資源')
}
isPlaying.value = true
error.value = null
} catch (err) {
error.value = err instanceof Error ? err.message : '播放失敗'
isPlaying.value = false
$q.notify({
type: 'negative',
message: error.value
})
}
}
// 暫停音頻
const pause = () => {
if (currentAudioSource) {
currentAudioSource.stop()
currentAudioSource = null
}
if (htmlAudio) {
htmlAudio.pause()
}
isPlaying.value = false
}
// 停止音頻
const stop = () => {
pause()
currentTime.value = 0
if (htmlAudio) {
htmlAudio.currentTime = 0
}
}
// 設置音量
const setVolume = (newVolume: number) => {
volume.value = Math.max(0, Math.min(1, newVolume))
if (gainNode) {
gainNode.gain.value = volume.value
}
if (htmlAudio) {
htmlAudio.volume = volume.value
}
}
// 設置播放速度
const setPlaybackRate = (rate: number) => {
playbackRate.value = Math.max(0.25, Math.min(4, rate))
if (currentAudioSource) {
currentAudioSource.playbackRate.value = playbackRate.value
}
if (htmlAudio) {
htmlAudio.playbackRate = playbackRate.value
}
}
// 跳轉到指定時間
const seekTo = (time: number) => {
if (htmlAudio) {
htmlAudio.currentTime = Math.max(0, Math.min(duration.value, time))
currentTime.value = htmlAudio.currentTime
}
}
// 快速播放功能(用於詞彙學習)
const quickPlay = async (url: string, options?: AudioOptions) => {
const success = await loadAudio(url)
if (success) {
await play(options)
}
return success
}
// 銷毀資源
const cleanup = () => {
stop()
if (audioBuffer) {
audioBuffer = null
}
if (htmlAudio) {
htmlAudio.remove()
htmlAudio = null
}
if (audioContext && audioContext.state !== 'closed') {
audioContext.close()
audioContext = null
}
gainNode = null
currentAudioSource = null
}
// 組件卸載時清理資源
onUnmounted(() => {
cleanup()
})
return {
// 狀態
isPlaying,
isLoading,
duration,
currentTime,
volume,
playbackRate,
error,
// 方法
loadAudio,
play,
pause,
stop,
setVolume,
setPlaybackRate,
seekTo,
quickPlay,
cleanup
}
}

View File

@ -0,0 +1,174 @@
import { ref } from 'vue'
export interface BookmarkData {
id: string
title: string
url: string
vocabularyId?: string
description?: string
tags?: string[]
createdAt: Date
updatedAt: Date
}
const BOOKMARK_STORAGE_KEY = 'dramaling-vocabulary-bookmarks'
export function useBrowserBookmarks() {
const bookmarks = ref<BookmarkData[]>([])
const isBookmarked = ref(false)
const loadBookmarks = () => {
const stored = localStorage.getItem(BOOKMARK_STORAGE_KEY)
if (stored) {
try {
bookmarks.value = JSON.parse(stored).map((bookmark: any) => ({
...bookmark,
createdAt: new Date(bookmark.createdAt),
updatedAt: new Date(bookmark.updatedAt)
}))
} catch (error) {
console.error('Failed to load bookmarks:', error)
bookmarks.value = []
}
}
}
const saveBookmarks = () => {
localStorage.setItem(BOOKMARK_STORAGE_KEY, JSON.stringify(bookmarks.value))
}
const addBookmark = (data: Omit<BookmarkData, 'id' | 'createdAt' | 'updatedAt'>) => {
const bookmark: BookmarkData = {
...data,
id: `bookmark_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
createdAt: new Date(),
updatedAt: new Date()
}
bookmarks.value.push(bookmark)
saveBookmarks()
return bookmark
}
const removeBookmark = (id: string) => {
const index = bookmarks.value.findIndex(b => b.id === id)
if (index > -1) {
bookmarks.value.splice(index, 1)
saveBookmarks()
return true
}
return false
}
const toggleBookmark = (data: Omit<BookmarkData, 'id' | 'createdAt' | 'updatedAt'>) => {
const existing = bookmarks.value.find(b => b.url === data.url)
if (existing) {
removeBookmark(existing.id)
isBookmarked.value = false
return { bookmarked: false, bookmark: null }
} else {
const bookmark = addBookmark(data)
isBookmarked.value = true
return { bookmarked: true, bookmark }
}
}
const checkBookmarkStatus = (url: string) => {
const existing = bookmarks.value.find(b => b.url === url)
isBookmarked.value = !!existing
return isBookmarked.value
}
const getBookmarkByUrl = (url: string) => {
return bookmarks.value.find(b => b.url === url)
}
const getVocabularyBookmarks = (vocabularyId: string) => {
return bookmarks.value.filter(b => b.vocabularyId === vocabularyId)
}
const searchBookmarks = (query: string) => {
const lowerQuery = query.toLowerCase()
return bookmarks.value.filter(b =>
b.title.toLowerCase().includes(lowerQuery) ||
b.description?.toLowerCase().includes(lowerQuery) ||
b.tags?.some(tag => tag.toLowerCase().includes(lowerQuery))
)
}
const exportBookmarks = () => {
const data = {
exportedAt: new Date().toISOString(),
version: '1.0',
bookmarks: bookmarks.value
}
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: 'application/json'
})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `dramaling-bookmarks-${new Date().toISOString().split('T')[0]}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
const importBookmarks = (file: File): Promise<number> => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (e) => {
try {
const data = JSON.parse(e.target?.result as string)
if (data.bookmarks && Array.isArray(data.bookmarks)) {
const importedCount = data.bookmarks.length
const existingUrls = new Set(bookmarks.value.map(b => b.url))
const newBookmarks = data.bookmarks
.filter((b: any) => !existingUrls.has(b.url))
.map((b: any) => ({
...b,
id: `bookmark_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
createdAt: new Date(b.createdAt || Date.now()),
updatedAt: new Date(b.updatedAt || Date.now())
}))
bookmarks.value.push(...newBookmarks)
saveBookmarks()
resolve(newBookmarks.length)
} else {
reject(new Error('Invalid bookmark file format'))
}
} catch (error) {
reject(new Error('Failed to parse bookmark file'))
}
}
reader.onerror = () => reject(new Error('Failed to read file'))
reader.readAsText(file)
})
}
loadBookmarks()
return {
bookmarks,
isBookmarked,
loadBookmarks,
addBookmark,
removeBookmark,
toggleBookmark,
checkBookmarkStatus,
getBookmarkByUrl,
getVocabularyBookmarks,
searchBookmarks,
exportBookmarks,
importBookmarks
}
}

View File

@ -0,0 +1,280 @@
import { ref, onMounted, onUnmounted } from 'vue'
export interface KeyboardShortcut {
key: string
code: string
description: string
action: () => void
preventDefault?: boolean
ctrlKey?: boolean
shiftKey?: boolean
altKey?: boolean
metaKey?: boolean
}
export interface KeyboardOptions {
ignoreInputs?: boolean
ignoreContentEditable?: boolean
}
export function useKeyboard(options: KeyboardOptions = {}) {
const shortcuts = ref<Map<string, KeyboardShortcut>>(new Map())
const isEnabled = ref(true)
const lastKeyPressed = ref<string>('')
const keySequence = ref<string[]>([])
const defaultOptions: Required<KeyboardOptions> = {
ignoreInputs: true,
ignoreContentEditable: true,
...options
}
// 檢查是否應該忽略按鍵事件
const shouldIgnoreEvent = (event: KeyboardEvent): boolean => {
if (!isEnabled.value) return true
const target = event.target as HTMLElement
// 檢查是否在輸入框中
if (defaultOptions.ignoreInputs) {
if (target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement) {
return true
}
}
// 檢查是否在可編輯元素中
if (defaultOptions.ignoreContentEditable) {
if (target.contentEditable === 'true') {
return true
}
}
return false
}
// 生成快捷鍵的唯一標識符
const generateShortcutKey = (shortcut: Omit<KeyboardShortcut, 'action' | 'description'>): string => {
const modifiers = []
if (shortcut.ctrlKey) modifiers.push('ctrl')
if (shortcut.shiftKey) modifiers.push('shift')
if (shortcut.altKey) modifiers.push('alt')
if (shortcut.metaKey) modifiers.push('meta')
return [...modifiers, shortcut.code.toLowerCase()].join('+')
}
// 檢查事件是否匹配快捷鍵
const matchesShortcut = (event: KeyboardEvent, shortcut: KeyboardShortcut): boolean => {
return (
event.code === shortcut.code &&
!!event.ctrlKey === !!shortcut.ctrlKey &&
!!event.shiftKey === !!shortcut.shiftKey &&
!!event.altKey === !!shortcut.altKey &&
!!event.metaKey === !!shortcut.metaKey
)
}
// 註冊快捷鍵
const register = (shortcut: KeyboardShortcut) => {
const key = generateShortcutKey(shortcut)
shortcuts.value.set(key, shortcut)
}
// 批量註冊快捷鍵
const registerMultiple = (shortcutList: KeyboardShortcut[]) => {
shortcutList.forEach(shortcut => register(shortcut))
}
// 取消註冊快捷鍵
const unregister = (code: string, modifiers?: {
ctrlKey?: boolean
shiftKey?: boolean
altKey?: boolean
metaKey?: boolean
}) => {
const key = generateShortcutKey({
code,
key: '',
...modifiers
})
shortcuts.value.delete(key)
}
// 清空所有快捷鍵
const clear = () => {
shortcuts.value.clear()
}
// 啟用/禁用快捷鍵
const enable = () => {
isEnabled.value = true
}
const disable = () => {
isEnabled.value = false
}
const toggle = () => {
isEnabled.value = !isEnabled.value
}
// 獲取所有已註冊的快捷鍵
const getShortcuts = () => {
return Array.from(shortcuts.value.values())
}
// 按鍵事件處理器
const handleKeydown = (event: KeyboardEvent) => {
if (shouldIgnoreEvent(event)) return
lastKeyPressed.value = event.code
keySequence.value.push(event.code)
// 限制序列長度
if (keySequence.value.length > 5) {
keySequence.value.shift()
}
// 查找匹配的快捷鍵
for (const shortcut of shortcuts.value.values()) {
if (matchesShortcut(event, shortcut)) {
if (shortcut.preventDefault !== false) {
event.preventDefault()
}
try {
shortcut.action()
} catch (error) {
console.error('快捷鍵執行錯誤:', error)
}
break
}
}
}
// 常用快捷鍵預設集
const presets = {
// 詞彙學習相關
vocabulary: [
{
key: 'Space',
code: 'Space',
description: '播放/暫停音頻',
action: () => {}
},
{
key: 'ArrowRight',
code: 'ArrowRight',
description: '下一個詞彙',
action: () => {}
},
{
key: 'ArrowLeft',
code: 'ArrowLeft',
description: '上一個詞彙',
action: () => {}
},
{
key: 'h',
code: 'KeyH',
description: '顯示/隱藏幫助',
action: () => {}
},
{
key: 'a',
code: 'KeyA',
description: '切換自動播放',
action: () => {}
},
{
key: 'r',
code: 'KeyR',
description: '重播音頻',
action: () => {}
}
] as KeyboardShortcut[],
// 練習模式相關
practice: [
{
key: 'Enter',
code: 'Enter',
description: '提交答案',
action: () => {}
},
{
key: 'n',
code: 'KeyN',
description: '下一題',
action: () => {}
},
{
key: 's',
code: 'KeyS',
description: '跳過題目',
action: () => {}
},
{
key: 'Escape',
code: 'Escape',
description: '退出練習',
action: () => {}
}
] as KeyboardShortcut[],
// 通用導航
navigation: [
{
key: 'Escape',
code: 'Escape',
description: '返回上一頁',
action: () => {}
},
{
key: 'f',
code: 'KeyF',
description: '全螢幕模式',
action: () => {}
},
{
key: '/',
code: 'Slash',
description: '搜索',
action: () => {}
}
] as KeyboardShortcut[]
}
// 生命週期
onMounted(() => {
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
})
return {
// 狀態
shortcuts,
isEnabled,
lastKeyPressed,
keySequence,
// 方法
register,
registerMultiple,
unregister,
clear,
enable,
disable,
toggle,
getShortcuts,
// 預設集
presets
}
}

View File

@ -0,0 +1,194 @@
import { onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
export interface KeyboardShortcut {
key: string
ctrl?: boolean
alt?: boolean
shift?: boolean
meta?: boolean
action: () => void
description: string
}
export function useKeyboardShortcuts() {
const router = useRouter()
const shortcuts = new Map<string, KeyboardShortcut>()
const getShortcutKey = (shortcut: Omit<KeyboardShortcut, 'action' | 'description'>) => {
const modifiers = []
if (shortcut.ctrl) modifiers.push('ctrl')
if (shortcut.alt) modifiers.push('alt')
if (shortcut.shift) modifiers.push('shift')
if (shortcut.meta) modifiers.push('meta')
return `${modifiers.join('+')}-${shortcut.key.toLowerCase()}`
}
const registerShortcut = (shortcut: KeyboardShortcut) => {
const key = getShortcutKey(shortcut)
shortcuts.set(key, shortcut)
}
const unregisterShortcut = (shortcut: Omit<KeyboardShortcut, 'action' | 'description'>) => {
const key = getShortcutKey(shortcut)
shortcuts.delete(key)
}
const handleKeyDown = (event: KeyboardEvent) => {
// Skip if user is typing in input fields
const target = event.target as HTMLElement
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return
}
const modifiers = []
if (event.ctrlKey || event.metaKey) modifiers.push('ctrl')
if (event.altKey) modifiers.push('alt')
if (event.shiftKey) modifiers.push('shift')
if (event.metaKey && !event.ctrlKey) modifiers.push('meta')
const key = `${modifiers.join('+')}-${event.key.toLowerCase()}`
const shortcut = shortcuts.get(key)
if (shortcut) {
event.preventDefault()
shortcut.action()
}
}
const registerDefaultShortcuts = () => {
// Navigation shortcuts
registerShortcut({
key: 'h',
ctrl: true,
action: () => router.push('/learning'),
description: '返回學習首頁'
})
registerShortcut({
key: 'v',
ctrl: true,
action: () => router.push('/learning/vocabulary'),
description: '打開詞彙學習'
})
registerShortcut({
key: 'r',
ctrl: true,
action: () => router.push('/learning/vocabulary/review'),
description: '打開智能複習'
})
// Dictionary shortcut
registerShortcut({
key: 'F1',
action: () => {
// TODO: Open dictionary panel
console.log('Dictionary shortcut activated')
},
description: '打開字典'
})
// Markdown notes shortcut
registerShortcut({
key: 'n',
ctrl: true,
action: () => {
// TODO: Open markdown note editor
console.log('Open markdown note editor')
},
description: '開啟筆記編輯器'
})
// Help shortcut
registerShortcut({
key: '?',
shift: true,
action: () => {
// TODO: Show help/shortcuts panel
console.log('Help shortcuts panel')
},
description: '顯示快捷鍵說明'
})
// Search shortcut
registerShortcut({
key: 'f',
ctrl: true,
action: () => {
// TODO: Focus search input
console.log('Focus search')
},
description: '搜尋'
})
// Toggle sidebar shortcut
registerShortcut({
key: 'm',
ctrl: true,
action: () => {
// TODO: Toggle sidebar
console.log('Toggle sidebar')
},
description: '切換側邊欄'
})
// Settings shortcut
registerShortcut({
key: ',',
ctrl: true,
action: () => router.push('/profile/settings'),
description: '開啟設定'
})
// Profile shortcut
registerShortcut({
key: 'p',
ctrl: true,
action: () => router.push('/profile'),
description: '開啟個人檔案'
})
}
const getAllShortcuts = () => {
return Array.from(shortcuts.values())
}
const getShortcutsByCategory = () => {
const categories = {
navigation: [] as KeyboardShortcut[],
learning: [] as KeyboardShortcut[],
tools: [] as KeyboardShortcut[]
}
shortcuts.forEach(shortcut => {
if (['h', 'v', 'r', 'p', ','].includes(shortcut.key)) {
categories.navigation.push(shortcut)
} else if (['d', 'n', 'F1'].includes(shortcut.key)) {
categories.learning.push(shortcut)
} else {
categories.tools.push(shortcut)
}
})
return categories
}
onMounted(() => {
registerDefaultShortcuts()
document.addEventListener('keydown', handleKeyDown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeyDown)
shortcuts.clear()
})
return {
registerShortcut,
unregisterShortcut,
getAllShortcuts,
getShortcutsByCategory
}
}

View File

@ -0,0 +1,413 @@
import { ref, reactive, watch, onMounted, onUnmounted, readonly } from 'vue'
import { useVocabularyStore } from '@/stores/vocabulary'
// 跨標籤頁學習狀態同步
interface TabLearningSession {
tabId: string
sessionId: string | null
currentExerciseId: string | null
startTime: string
isActive: boolean
lastActivity: string
exerciseType: string
completedQuestions: number
totalQuestions: number
}
// 跨標籤頁消息類型
interface TabMessage {
type: 'session-start' | 'session-update' | 'session-complete' | 'sync-request' | 'sync-response' | 'tab-register' | 'tab-unregister'
tabId: string
payload?: any
timestamp: string
}
export function useMultiTabLearning() {
const vocabularyStore = useVocabularyStore()
// 當前標籤頁ID
const currentTabId = ref(`tab_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`)
// 所有活躍標籤頁的學習會話
const activeTabs = reactive<Map<string, TabLearningSession>>(new Map())
// 同步狀態
const isSyncing = ref(false)
const syncConflicts = ref<string[]>([])
// 廣播通道 (用於跨標籤頁通信)
let broadcastChannel: BroadcastChannel | null = null
let heartbeatInterval: number | null = null
// 初始化廣播通道
const initializeBroadcastChannel = () => {
if ('BroadcastChannel' in window) {
broadcastChannel = new BroadcastChannel('dramaling-multi-tab')
broadcastChannel.onmessage = (event: MessageEvent<TabMessage>) => {
handleTabMessage(event.data)
}
// 註冊當前標籤頁
broadcastMessage({
type: 'tab-register',
tabId: currentTabId.value,
payload: {
url: window.location.href,
userAgent: navigator.userAgent
},
timestamp: new Date().toISOString()
})
// 請求其他標籤頁的狀態
setTimeout(() => {
broadcastMessage({
type: 'sync-request',
tabId: currentTabId.value,
timestamp: new Date().toISOString()
})
}, 100)
}
}
// 發送廣播消息
const broadcastMessage = (message: TabMessage) => {
if (broadcastChannel) {
broadcastChannel.postMessage(message)
}
}
// 處理來自其他標籤頁的消息
const handleTabMessage = (message: TabMessage) => {
if (message.tabId === currentTabId.value) return // 忽略自己的消息
switch (message.type) {
case 'tab-register':
activeTabs.set(message.tabId, {
tabId: message.tabId,
sessionId: null,
currentExerciseId: null,
startTime: message.timestamp,
isActive: true,
lastActivity: message.timestamp,
exerciseType: '',
completedQuestions: 0,
totalQuestions: 0
})
break
case 'tab-unregister':
activeTabs.delete(message.tabId)
break
case 'session-start':
if (activeTabs.has(message.tabId)) {
const tab = activeTabs.get(message.tabId)!
Object.assign(tab, {
sessionId: message.payload.sessionId,
currentExerciseId: message.payload.currentExerciseId,
exerciseType: message.payload.exerciseType,
totalQuestions: message.payload.totalQuestions,
lastActivity: message.timestamp
})
}
// 檢查衝突
checkSessionConflicts()
break
case 'session-update':
if (activeTabs.has(message.tabId)) {
const tab = activeTabs.get(message.tabId)!
Object.assign(tab, {
currentExerciseId: message.payload.currentExerciseId,
completedQuestions: message.payload.completedQuestions,
lastActivity: message.timestamp
})
}
break
case 'session-complete':
if (activeTabs.has(message.tabId)) {
const tab = activeTabs.get(message.tabId)!
Object.assign(tab, {
sessionId: null,
currentExerciseId: null,
lastActivity: message.timestamp
})
}
// 同步進度
syncProgressFromOtherTabs()
break
case 'sync-request':
// 回應同步請求
sendCurrentState()
break
case 'sync-response':
// 處理其他標籤頁的狀態
if (activeTabs.has(message.tabId)) {
const tab = activeTabs.get(message.tabId)!
Object.assign(tab, message.payload)
} else {
activeTabs.set(message.tabId, message.payload)
}
break
}
}
// 發送當前狀態
const sendCurrentState = () => {
const currentSession = vocabularyStore.currentSession
broadcastMessage({
type: 'sync-response',
tabId: currentTabId.value,
payload: {
tabId: currentTabId.value,
sessionId: currentSession?.id || null,
currentExerciseId: getCurrentExerciseId(),
startTime: currentSession?.start_time || new Date().toISOString(),
isActive: true,
lastActivity: new Date().toISOString(),
exerciseType: currentSession?.exercise_type || '',
completedQuestions: currentSession?.completed_questions || 0,
totalQuestions: currentSession?.total_questions || 0
},
timestamp: new Date().toISOString()
})
}
// 獲取當前練習ID
const getCurrentExerciseId = () => {
const currentSession = vocabularyStore.currentSession
if (!currentSession) return null
const exercises = vocabularyStore.currentExercises
const index = currentSession.completed_questions
return exercises[index]?.id || null
}
// 檢查會話衝突
const checkSessionConflicts = () => {
const conflicts: string[] = []
const currentSession = vocabularyStore.currentSession
if (currentSession) {
for (const [tabId, session] of activeTabs.entries()) {
if (session.sessionId && session.exerciseType === currentSession.exercise_type) {
conflicts.push(tabId)
}
}
}
syncConflicts.value = conflicts
}
// 從其他標籤頁同步進度
const syncProgressFromOtherTabs = async () => {
isSyncing.value = true
try {
// 模擬從其他標籤頁同步進度
// 實際實現中,這裡會處理來自其他標籤頁的學習進度數據
await new Promise(resolve => setTimeout(resolve, 500))
console.log('Progress synced from other tabs')
} catch (error) {
console.error('Failed to sync progress from other tabs:', error)
} finally {
isSyncing.value = false
}
}
// 開始學習會話
const startMultiTabSession = async (vocabularyIds: string[], exerciseType: string) => {
try {
await vocabularyStore.startExerciseSession(vocabularyIds, exerciseType as any)
const session = vocabularyStore.currentSession
if (session) {
broadcastMessage({
type: 'session-start',
tabId: currentTabId.value,
payload: {
sessionId: session.id,
currentExerciseId: getCurrentExerciseId(),
exerciseType: session.exercise_type,
totalQuestions: session.total_questions
},
timestamp: new Date().toISOString()
})
}
} catch (error) {
console.error('Failed to start multi-tab session:', error)
throw error
}
}
// 更新會話進度
const updateSessionProgress = () => {
const session = vocabularyStore.currentSession
if (session) {
broadcastMessage({
type: 'session-update',
tabId: currentTabId.value,
payload: {
currentExerciseId: getCurrentExerciseId(),
completedQuestions: session.completed_questions
},
timestamp: new Date().toISOString()
})
}
}
// 完成學習會話
const completeMultiTabSession = async () => {
try {
await vocabularyStore.completeSession()
broadcastMessage({
type: 'session-complete',
tabId: currentTabId.value,
payload: {},
timestamp: new Date().toISOString()
})
} catch (error) {
console.error('Failed to complete multi-tab session:', error)
throw error
}
}
// 解決衝突
const resolveConflict = (strategy: 'merge' | 'override' | 'cancel') => {
switch (strategy) {
case 'merge':
// 合併多個標籤頁的進度
mergeTabProgress()
break
case 'override':
// 使用當前標籤頁的進度覆蓋其他標籤頁
overrideOtherTabs()
break
case 'cancel':
// 取消當前標籤頁的會話
vocabularyStore.resetCurrentSession()
break
}
syncConflicts.value = []
}
// 合併標籤頁進度
const mergeTabProgress = () => {
// 實現進度合併邏輯
console.log('Merging progress from multiple tabs')
}
// 覆蓋其他標籤頁
const overrideOtherTabs = () => {
// 通知其他標籤頁停止會話
broadcastMessage({
type: 'session-complete',
tabId: currentTabId.value,
payload: { force: true },
timestamp: new Date().toISOString()
})
}
// 心跳檢測
const startHeartbeat = () => {
heartbeatInterval = window.setInterval(() => {
// 更新當前標籤頁的活動時間
if (activeTabs.has(currentTabId.value)) {
const tab = activeTabs.get(currentTabId.value)!
tab.lastActivity = new Date().toISOString()
}
// 清理非活躍的標籤頁
const now = Date.now()
for (const [tabId, session] of activeTabs.entries()) {
const lastActivity = new Date(session.lastActivity).getTime()
if (now - lastActivity > 30000) { // 30秒無活動視為非活躍
activeTabs.delete(tabId)
}
}
}, 5000)
}
// 停止心跳檢測
const stopHeartbeat = () => {
if (heartbeatInterval) {
clearInterval(heartbeatInterval)
heartbeatInterval = null
}
}
// 清理資源
const cleanup = () => {
if (broadcastChannel) {
broadcastMessage({
type: 'tab-unregister',
tabId: currentTabId.value,
timestamp: new Date().toISOString()
})
broadcastChannel.close()
broadcastChannel = null
}
stopHeartbeat()
}
// 監聽詞彙存儲變化
watch(
() => vocabularyStore.currentSession,
(newSession, oldSession) => {
if (newSession && !oldSession) {
// 會話開始
updateSessionProgress()
} else if (!newSession && oldSession) {
// 會話結束
completeMultiTabSession()
} else if (newSession && oldSession && newSession.completed_questions !== oldSession.completed_questions) {
// 進度更新
updateSessionProgress()
}
},
{ deep: true }
)
// 組件掛載時初始化
onMounted(() => {
initializeBroadcastChannel()
startHeartbeat()
// 頁面卸載時清理
window.addEventListener('beforeunload', cleanup)
})
// 組件卸載時清理
onUnmounted(() => {
cleanup()
window.removeEventListener('beforeunload', cleanup)
})
return {
currentTabId: readonly(currentTabId),
activeTabs: readonly(activeTabs),
isSyncing: readonly(isSyncing),
syncConflicts: readonly(syncConflicts),
// 方法
startMultiTabSession,
updateSessionProgress,
completeMultiTabSession,
resolveConflict,
syncProgressFromOtherTabs
}
}

View File

@ -0,0 +1,551 @@
<template>
<div class="app-layout">
<!-- 側邊欄 -->
<aside class="app-sidebar" :class="{ 'app-sidebar--collapsed': uiStore.sidebarCollapsed }">
<div class="sidebar-header">
<router-link to="/learning" class="sidebar-logo">
<img src="/logo.svg" alt="Drama Ling" />
<Transition name="fade">
<span v-if="!uiStore.sidebarCollapsed">Drama Ling</span>
</Transition>
</router-link>
<QBtn
flat
round
dense
:icon="uiStore.sidebarCollapsed ? 'menu_open' : 'menu'"
@click="uiStore.toggleSidebar"
class="sidebar-toggle"
/>
</div>
<nav class="sidebar-nav">
<div class="nav-section">
<router-link
v-for="item in mainNavItems"
:key="item.name"
:to="item.to"
class="nav-item"
:class="{ 'nav-item--active': $route.name === item.name }"
>
<QIcon :name="item.icon" />
<Transition name="fade">
<span v-if="!uiStore.sidebarCollapsed">{{ item.label }}</span>
</Transition>
<div v-if="item.badge" class="nav-badge">{{ item.badge }}</div>
</router-link>
</div>
<div class="nav-divider"></div>
<div class="nav-section">
<router-link
v-for="item in secondaryNavItems"
:key="item.name"
:to="item.to"
class="nav-item"
:class="{ 'nav-item--active': $route.name === item.name }"
>
<QIcon :name="item.icon" />
<Transition name="fade">
<span v-if="!uiStore.sidebarCollapsed">{{ item.label }}</span>
</Transition>
</router-link>
</div>
</nav>
<div class="sidebar-footer">
<div class="user-profile" @click="toggleProfileMenu">
<div class="user-avatar">
<img v-if="userStore.profile?.avatar" :src="userStore.profile.avatar" alt="頭像" />
<QIcon v-else name="person" />
</div>
<Transition name="fade">
<div v-if="!uiStore.sidebarCollapsed" class="user-info">
<div class="user-name">{{ authStore.userDisplayName }}</div>
<div class="user-level">Level {{ userStore.currentLevel }}</div>
</div>
</Transition>
<QIcon name="expand_more" />
</div>
</div>
</aside>
<!-- 主要內容區域 -->
<main class="app-main">
<!-- 頂部導航欄 -->
<header class="app-header">
<div class="header-left">
<QBtn
flat
round
dense
icon="menu"
@click="uiStore.toggleSidebar"
class="mobile-menu-btn"
/>
<div class="breadcrumbs">
<QBreadcrumbs>
<QBreadcrumbsEl
v-for="(crumb, index) in uiStore.breadcrumbs"
:key="index"
:label="crumb.label"
:to="crumb.to"
/>
</QBreadcrumbs>
</div>
</div>
<div class="header-right">
<QBtn
flat
round
dense
icon="notifications"
class="notification-btn"
@click="toggleNotifications"
>
<QBadge v-if="notificationCount > 0" color="red" floating>
{{ notificationCount }}
</QBadge>
</QBtn>
<QBtn
flat
round
dense
:icon="uiStore.isDarkMode ? 'light_mode' : 'dark_mode'"
@click="toggleTheme"
/>
<div class="streak-display">
<QIcon name="local_fire_department" />
<span>{{ userStore.streakDays }}</span>
</div>
</div>
</header>
<!-- 頁面內容 -->
<div class="app-content">
<router-view />
</div>
</main>
<!-- 移動端底部導航 -->
<nav class="mobile-nav">
<router-link
v-for="item in mobileNavItems"
:key="item.name"
:to="item.to"
class="mobile-nav-item"
:class="{ 'mobile-nav-item--active': $route.name === item.name }"
>
<QIcon :name="item.icon" />
<span>{{ item.label }}</span>
<div v-if="item.badge" class="mobile-nav-badge">{{ item.badge }}</div>
</router-link>
</nav>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useUserStore } from '@/stores/user'
import { useUIStore } from '@/stores/ui'
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
const authStore = useAuthStore()
const userStore = useUserStore()
const uiStore = useUIStore()
//
const { registerShortcut } = useKeyboardShortcuts()
const notificationCount = ref(3)
const mainNavItems = [
{ name: 'learning', to: '/learning', icon: 'school', label: '學習地圖' },
{ name: 'vocabulary', to: '/learning/vocabulary', icon: 'book', label: '詞彙練習', badge: userStore.reviewDueVocabulary.length || null },
{ name: 'vocabulary-review', to: '/learning/vocabulary/review', icon: 'refresh', label: '智能複習' },
{ name: 'dialogue', to: '/learning/dialogue', icon: 'chat', label: '對話練習' },
{ name: 'roleplay', to: '/learning/roleplay', icon: 'theater_comedy', label: '角色扮演' },
{ name: 'pronunciation', to: '/learning/pronunciation', icon: 'mic', label: '發音練習' }
]
const secondaryNavItems = [
{ name: 'vocabulary-analytics', to: '/learning/vocabulary/analytics', icon: 'analytics', label: '詞彙分析儀表板' },
{ name: 'progress', to: '/profile/progress', icon: 'trending_up', label: '學習進度' },
{ name: 'profile', to: '/profile', icon: 'person', label: '個人檔案' },
{ name: 'shop', to: '/shop', icon: 'shopping_cart', label: '商店' },
{ name: 'settings', to: '/profile/settings', icon: 'settings', label: '設定' }
]
const mobileNavItems = [
{ name: 'learning', to: '/learning', icon: 'home', label: '首頁' },
{ name: 'vocabulary', to: '/learning/vocabulary', icon: 'book', label: '詞彙', badge: userStore.reviewDueVocabulary.length || null },
{ name: 'dialogue', to: '/learning/dialogue', icon: 'chat', label: '對話' },
{ name: 'progress', to: '/profile/progress', icon: 'trending_up', label: '進度' },
{ name: 'profile', to: '/profile', icon: 'person', label: '我的' }
]
const toggleProfileMenu = () => {
// TODO:
console.log('個人檔案選單')
}
const toggleNotifications = () => {
// TODO:
console.log('通知面板')
}
const toggleTheme = () => {
const newTheme = uiStore.theme === 'dark' ? 'light' : 'dark'
uiStore.setTheme(newTheme)
}
</script>
<style lang="scss" scoped>
.app-layout {
display: flex;
min-height: 100vh;
background: $background-primary;
}
.app-sidebar {
width: 280px;
background: $card-background;
border-right: 1px solid $divider;
display: flex;
flex-direction: column;
transition: width 0.3s ease;
z-index: 900;
@include respond-to(md) {
position: fixed;
top: 0;
left: 0;
height: 100vh;
transform: translateX(-100%);
&:not(.app-sidebar--collapsed) {
transform: translateX(0);
}
}
&--collapsed {
width: 80px;
.sidebar-header {
padding: $space-4 $space-3;
}
.nav-item {
justify-content: center;
padding: $space-3;
.q-icon {
margin-right: 0;
}
}
}
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: $space-4 $space-6;
border-bottom: 1px solid $divider;
}
.sidebar-logo {
display: flex;
align-items: center;
gap: $space-3;
text-decoration: none;
color: $text-primary;
font-weight: 700;
font-size: $text-lg;
img {
width: 32px;
height: 32px;
}
}
.sidebar-toggle {
color: $text-secondary;
@include respond-to(md) {
display: none;
}
}
.sidebar-nav {
flex: 1;
padding: $space-4 0;
overflow-y: auto;
}
.nav-section {
padding: 0 $space-3;
margin-bottom: $space-2;
}
.nav-item {
display: flex;
align-items: center;
gap: $space-3;
padding: $space-3 $space-4;
margin-bottom: $space-1;
border-radius: $radius-lg;
color: $text-secondary;
text-decoration: none;
font-weight: 500;
transition: all 0.3s ease;
position: relative;
&:hover {
background: rgba($primary-teal, 0.1);
color: $primary-teal;
}
&--active {
background: $primary-teal;
color: $background-dark;
&:hover {
background: $primary-teal-light;
}
}
.q-icon {
font-size: 20px;
}
}
.nav-badge {
margin-left: auto;
background: $error-red;
color: white;
font-size: $text-xs;
padding: 2px 6px;
border-radius: $radius-full;
min-width: 18px;
text-align: center;
}
.nav-divider {
height: 1px;
background: $divider;
margin: $space-4 $space-6;
}
.sidebar-footer {
padding: $space-4 $space-6;
border-top: 1px solid $divider;
}
.user-profile {
display: flex;
align-items: center;
gap: $space-3;
padding: $space-3;
border-radius: $radius-lg;
cursor: pointer;
transition: background 0.3s ease;
&:hover {
background: rgba($text-secondary, 0.1);
}
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: $radius-full;
background: $primary-teal;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.q-icon {
color: $background-dark;
font-size: 20px;
}
}
.user-info {
flex: 1;
min-width: 0;
}
.user-name {
font-weight: 600;
font-size: $text-sm;
color: $text-primary;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-level {
font-size: $text-xs;
color: $text-secondary;
}
.app-main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
@include respond-to(md) {
padding-bottom: 80px; //
}
}
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: $space-4 $space-6;
background: $card-background;
border-bottom: 1px solid $divider;
@include respond-to(md) {
padding: $space-3 $space-4;
}
}
.header-left {
display: flex;
align-items: center;
gap: $space-4;
flex: 1;
min-width: 0;
}
.mobile-menu-btn {
display: none;
color: $text-secondary;
@include respond-to(md) {
display: inline-flex;
}
}
.breadcrumbs {
color: $text-secondary;
font-size: $text-sm;
}
.header-right {
display: flex;
align-items: center;
gap: $space-2;
}
.notification-btn {
color: $text-secondary;
position: relative;
}
.streak-display {
display: flex;
align-items: center;
gap: $space-1;
padding: $space-2 $space-3;
background: rgba($warning-orange, 0.1);
color: $warning-orange;
border-radius: $radius-lg;
font-weight: 600;
font-size: $text-sm;
.q-icon {
font-size: 16px;
}
}
.app-content {
flex: 1;
overflow-y: auto;
padding: $space-6;
@include respond-to(md) {
padding: $space-4;
}
}
.mobile-nav {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: $card-background;
border-top: 1px solid $divider;
padding: $space-2;
z-index: 950;
@include respond-to(md) {
display: flex;
}
}
.mobile-nav-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: $space-1;
padding: $space-2;
color: $text-secondary;
text-decoration: none;
font-size: $text-xs;
font-weight: 500;
position: relative;
&--active {
color: $primary-teal;
}
.q-icon {
font-size: 20px;
}
}
.mobile-nav-badge {
position: absolute;
top: $space-1;
right: 25%;
background: $error-red;
color: white;
font-size: 8px;
padding: 1px 4px;
border-radius: $radius-full;
min-width: 12px;
text-align: center;
}
//
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@ -0,0 +1,333 @@
<template>
<div class="auth-layout">
<div class="auth-layout__background">
<div class="auth-layout__pattern"></div>
</div>
<div class="auth-layout__container">
<div class="auth-layout__content">
<!-- Logo 區域 -->
<div class="auth-layout__header">
<router-link to="/" class="auth-layout__logo">
<img src="/logo.svg" alt="Drama Ling" />
<h1>Drama Ling</h1>
</router-link>
<p class="auth-layout__subtitle">戲劇式語言學習平台</p>
</div>
<!-- 內容區域 -->
<div class="auth-layout__main">
<router-view />
</div>
<!-- 語言切換 -->
<div class="auth-layout__footer">
<div class="auth-layout__language">
<QBtn
flat
dense
icon="language"
:label="currentLanguage"
@click="toggleLanguage"
/>
</div>
<div class="auth-layout__links">
<a href="/privacy" target="_blank">隱私政策</a>
<a href="/terms" target="_blank">使用條款</a>
<a href="/support" target="_blank">技術支援</a>
</div>
</div>
</div>
<!-- 右側說明區域 -->
<div class="auth-layout__info">
<div class="auth-layout__info-content">
<h2>開始你的語言學習之旅</h2>
<p>透過戲劇化的對話練習讓語言學習變得生動有趣從基礎對話到流利表達我們陪伴你的每一步成長</p>
<div class="auth-layout__features">
<div class="feature">
<QIcon name="theater_comedy" />
<div>
<h3>戲劇化學習</h3>
<p>透過角色扮演和情境對話提升語言表達能力</p>
</div>
</div>
<div class="feature">
<QIcon name="mic" />
<div>
<h3>發音練習</h3>
<p>AI 語音識別系統即時糾正發音問題</p>
</div>
</div>
<div class="feature">
<QIcon name="timeline" />
<div>
<h3>個人化進度</h3>
<p>智能學習路徑規劃適應個人學習節奏</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const currentLanguage = ref('繁體中文')
const toggleLanguage = () => {
// TODO:
console.log('語言切換功能待實現')
}
</script>
<style lang="scss" scoped>
.auth-layout {
min-height: 100vh;
display: flex;
position: relative;
&__background {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg,
$primary-teal 0%,
$secondary-purple 50%,
$accent-violet 100%);
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba($background-dark, 0.3);
backdrop-filter: blur(1px);
}
}
&__pattern {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
radial-gradient(circle at 25% 25%, rgba(255,255,255,0.1) 0%, transparent 50%),
radial-gradient(circle at 75% 75%, rgba(255,255,255,0.1) 0%, transparent 50%);
background-size: 100px 100px;
animation: float 20s ease-in-out infinite;
}
&__container {
display: flex;
width: 100%;
position: relative;
z-index: 1;
}
&__content {
flex: 1;
max-width: 500px;
padding: $space-8;
display: flex;
flex-direction: column;
justify-content: center;
background: rgba($card-background, 0.95);
backdrop-filter: blur(20px);
border-right: 1px solid rgba($divider, 0.1);
@include respond-to(md) {
max-width: none;
padding: $space-6;
}
}
&__header {
text-align: center;
margin-bottom: $space-8;
}
&__logo {
display: flex;
flex-direction: column;
align-items: center;
gap: $space-3;
text-decoration: none;
color: inherit;
transition: transform 0.3s ease;
&:hover {
transform: scale(1.05);
}
img {
width: 64px;
height: 64px;
object-fit: contain;
}
h1 {
font-size: $text-2xl;
font-weight: 700;
margin: 0;
background: linear-gradient(135deg, $primary-teal, $secondary-purple);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
}
&__subtitle {
font-size: $text-sm;
color: $text-secondary;
margin: $space-2 0 0 0;
}
&__main {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
&__footer {
margin-top: $space-8;
display: flex;
flex-direction: column;
gap: $space-4;
align-items: center;
}
&__language {
.q-btn {
color: $text-secondary;
&:hover {
color: $primary-teal;
background: rgba($primary-teal, 0.1);
}
}
}
&__links {
display: flex;
gap: $space-6;
a {
font-size: $text-xs;
color: $text-tertiary;
text-decoration: none;
transition: color 0.3s ease;
&:hover {
color: $primary-teal;
}
}
}
&__info {
flex: 1;
padding: $space-8;
display: flex;
align-items: center;
justify-content: center;
@include respond-to(md) {
display: none;
}
&-content {
max-width: 500px;
color: $text-primary-inverse;
h2 {
font-size: $text-3xl;
font-weight: 700;
margin-bottom: $space-4;
line-height: 1.3;
}
> p {
font-size: $text-lg;
line-height: 1.6;
margin-bottom: $space-8;
opacity: 0.9;
}
}
}
&__features {
display: flex;
flex-direction: column;
gap: $space-6;
.feature {
display: flex;
gap: $space-4;
align-items: flex-start;
.q-icon {
flex-shrink: 0;
font-size: 32px;
color: $primary-teal;
margin-top: $space-1;
}
h3 {
font-size: $text-lg;
font-weight: 600;
margin: 0 0 $space-2 0;
}
p {
font-size: $text-base;
margin: 0;
opacity: 0.8;
line-height: 1.5;
}
}
}
}
@keyframes float {
0%, 100% {
transform: translateY(0px) rotate(0deg);
}
50% {
transform: translateY(-20px) rotate(5deg);
}
}
//
@include respond-to(xs) {
.auth-layout {
&__content {
padding: $space-4;
}
&__header {
margin-bottom: $space-6;
}
&__footer {
margin-top: $space-6;
}
&__links {
gap: $space-4;
text-align: center;
}
}
}
</style>

View File

@ -1,76 +0,0 @@
// Main Application Entry Point
import './styles/main.scss';
import { VocabularyApp } from './modules/VocabularyApp.js';
import { VocabularyState } from './modules/VocabularyState.js';
class DramaLingApp {
constructor() {
console.log('🚀 Initializing Drama Ling App...');
this.state = new VocabularyState();
this.vocabulary = new VocabularyApp(this.state);
this.init();
}
async init() {
try {
// Wait for DOM to be ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => this.setup());
} else {
await this.setup();
}
} catch (error) {
console.error('App initialization failed:', error);
this.showError('應用程式初始化失敗');
}
}
async setup() {
const app = document.getElementById('app');
try {
// Initialize vocabulary app
await this.vocabulary.init();
// Replace loading spinner with vocabulary app
app.innerHTML = this.vocabulary.render();
// Bind event listeners
this.vocabulary.bindEvents();
console.log('📚 Drama Ling 詞彙學習應用已載入');
} catch (error) {
console.error('Setup failed:', error);
this.showError('載入詞彙學習功能失敗');
}
}
showError(message) {
const app = document.getElementById('app');
app.innerHTML = `
<div style="
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
color: var(--error-red);
font-size: var(--text-lg);
">
${message}
<br><br>
<button onclick="location.reload()" style="
padding: var(--space-3) var(--space-6);
background: var(--primary-teal);
color: white;
border: none;
border-radius: var(--radius-lg);
cursor: pointer;
">重新載入</button>
</div>
`;
}
}
// Initialize app
new DramaLingApp();

35
apps/web/src/main.ts Normal file
View File

@ -0,0 +1,35 @@
console.log('main.ts loading...')
import { createApp } from 'vue'
import { Quasar, Notify, Loading, Dialog } from 'quasar'
import App from './App.vue'
import router from './router'
import { pinia } from './stores'
// Quasar樣式
import 'quasar/dist/quasar.css'
import '@quasar/extras/material-icons/material-icons.css'
console.log('Creating Vue app...')
const app = createApp(App)
console.log('Adding Quasar...')
app.use(Quasar, {
plugins: {
Notify,
Loading,
Dialog
}
})
console.log('Adding Pinia...')
app.use(pinia)
console.log('Adding router...')
app.use(router)
console.log('Mounting Vue app...')
app.mount('#app')
console.log('Vue app mounted!')

View File

@ -1,585 +0,0 @@
// Vocabulary Learning Application Main Module
import { AudioManager } from '../utils/AudioManager.js';
export class VocabularyApp {
constructor(state) {
this.state = state;
this.currentMode = 'flashcard';
this.isCardFlipped = false;
this.isMobileMenuOpen = false;
this.unsubscribe = null;
this.audioManager = new AudioManager();
}
async init() {
// Subscribe to state changes
this.unsubscribe = this.state.subscribe((event, data) => {
this.onStateChange(event, data);
});
// Set initial current word if none selected
if (!this.state.getCurrentWord()) {
const reviewQueue = this.state.getReviewQueue();
const newWords = this.state.getNewWords(1);
const nextWord = reviewQueue[0] || newWords[0];
if (nextWord) {
this.state.setCurrentWord(nextWord);
}
}
console.log('📚 VocabularyApp initialized');
}
render() {
const progress = this.state.getProgress();
const currentWord = this.state.getCurrentWord();
return `
<div class="vocabulary-layout">
${this.renderSidebar()}
${this.renderMainContent(progress, currentWord)}
</div>
`;
}
renderSidebar() {
return `
<!-- 手機版選單按鈕 -->
<button class="mobile-menu-btn" id="mobileMenuBtn"></button>
<!-- 側邊欄 -->
<aside class="sidebar ${this.isMobileMenuOpen ? 'open' : ''}" id="sidebar">
<div class="sidebar-header">
<a href="#" class="logo">
🎭 Drama Ling
</a>
</div>
<nav class="sidebar-nav">
<div class="nav-section">
<div class="nav-section-title">主要功能</div>
<a href="#" class="nav-item">
<span class="nav-icon">📊</span>
學習儀表板
</a>
<a href="#" class="nav-item active">
<span class="nav-icon">📚</span>
詞彙學習
</a>
<a href="#" class="nav-item">
<span class="nav-icon">💬</span>
對話練習
</a>
<a href="#" class="nav-item">
<span class="nav-icon">🎭</span>
角色扮演
</a>
<a href="#" class="nav-item">
<span class="nav-icon">🎵</span>
發音練習
</a>
</div>
<div class="nav-section">
<div class="nav-section-title">個人管理</div>
<a href="#" class="nav-item">
<span class="nav-icon">👤</span>
個人檔案
</a>
<a href="#" class="nav-item">
<span class="nav-icon">📈</span>
學習進度
</a>
<a href="#" class="nav-item">
<span class="nav-icon"></span>
設定
</a>
</div>
<div class="nav-section">
<div class="nav-section-title">訂閱服務</div>
<a href="#" class="nav-item">
<span class="nav-icon">💎</span>
訂閱管理
</a>
</div>
</nav>
<div class="sidebar-footer">
<div class="user-profile">
<div class="user-avatar"></div>
<div class="user-info">
<div class="user-name">張小明</div>
<div class="user-level">Level 12</div>
</div>
</div>
</div>
</aside>
`;
}
renderMainContent(progress, currentWord) {
return `
<!-- 主內容區 -->
<main class="main-content">
${this.renderPageHeader(progress)}
${this.renderModeSelector()}
${this.renderFlashcardSection(currentWord)}
${this.renderVocabularyList()}
</main>
`;
}
renderPageHeader(progress) {
return `
<div class="page-header">
<div class="header-section">
<div class="header-text">
<h1>詞彙學習</h1>
<p>透過間隔重複和情境學習有效掌握新詞彙</p>
</div>
<div class="header-stats">
<div class="stat-item">
<span class="stat-value">${progress.learned || 0}</span>
<span class="stat-label">已學詞彙</span>
</div>
<div class="stat-item">
<span class="stat-value">${progress.todayNew || 0}</span>
<span class="stat-label">今日新增</span>
</div>
<div class="stat-item">
<span class="stat-value">${progress.masteryRate || 0}%</span>
<span class="stat-label">掌握程度</span>
</div>
</div>
</div>
</div>
`;
}
renderModeSelector() {
const reviewQueue = this.state.getReviewQueue();
const newWords = this.state.getNewWords();
return `
<!-- 學習模式選擇 -->
<div class="mode-selector">
<div class="mode-card ${this.currentMode === 'flashcard' ? 'active' : ''}" data-mode="flashcard">
<div class="mode-icon">🃏</div>
<h3 class="mode-title">記憶卡片</h3>
<p class="mode-description">透過卡片翻轉快速記憶新詞彙</p>
<div class="mode-progress">
<span>待複習: ${reviewQueue.length}</span>
<span>新詞彙: ${newWords.length}</span>
</div>
</div>
<div class="mode-card ${this.currentMode === 'quiz' ? 'active' : ''}" data-mode="quiz">
<div class="mode-icon">🎯</div>
<h3 class="mode-title">詞彙測驗</h3>
<p class="mode-description">選擇題和填空題測試詞彙掌握</p>
<div class="mode-progress">
<span>正確率: 92%</span>
<span>完成: 45/50</span>
</div>
</div>
<div class="mode-card ${this.currentMode === 'context' ? 'active' : ''}" data-mode="context">
<div class="mode-icon">📖</div>
<h3 class="mode-title">情境學習</h3>
<p class="mode-description">在真實情境中學習詞彙運用</p>
<div class="mode-progress">
<span>場景: 咖啡廳</span>
<span>進度: 3/5</span>
</div>
</div>
</div>
`;
}
renderFlashcardSection(currentWord) {
if (!currentWord) {
return `
<div class="vocabulary-section" id="flashcardSection">
<div class="vocabulary-card">
<div class="no-words-message">
<h3>🎉 太棒了</h3>
<p>目前沒有需要複習的詞彙</p>
<button class="control-btn primary" onclick="location.reload()">
重新開始學習
</button>
</div>
</div>
</div>
`;
}
return `
<!-- 詞彙卡片學習區 -->
<div class="vocabulary-section ${this.currentMode === 'flashcard' ? '' : 'hidden'}" id="flashcardSection">
<div class="vocabulary-card">
<div class="vocabulary-word">${currentWord.word}</div>
<div class="vocabulary-phonetic">${currentWord.phonetic}</div>
<div class="vocabulary-definition ${this.isCardFlipped ? '' : 'hidden'}">${currentWord.definition}</div>
<div class="vocabulary-example ${this.isCardFlipped ? '' : 'hidden'}">
"${currentWord.example}"<br>
${currentWord.translation}
</div>
<div class="vocabulary-controls">
<button class="control-btn" id="playAudioBtn">
🔊 發音
</button>
<button class="control-btn" id="flipCardBtn">
🔄 ${this.isCardFlipped ? '翻回' : '翻轉'}
</button>
<button class="control-btn primary" id="nextCardBtn">
下一個
</button>
</div>
<div class="difficulty-buttons ${this.isCardFlipped ? '' : 'hidden'}">
<button class="difficulty-btn easy" data-difficulty="easy">簡單 (3天後)</button>
<button class="difficulty-btn" data-difficulty="normal">普通 (1天後)</button>
<button class="difficulty-btn hard" data-difficulty="hard">困難 (10分鐘後)</button>
</div>
</div>
</div>
`;
}
renderVocabularyList() {
const allWords = this.state.getAllWords();
return `
<!-- 詞彙清單 -->
<div class="vocabulary-list">
<div class="list-header">
<h2 class="list-title">我的詞彙庫</h2>
<div class="filter-tabs">
<button class="filter-tab active" data-filter="all">全部</button>
<button class="filter-tab" data-filter="learning">學習中</button>
<button class="filter-tab" data-filter="learned">已掌握</button>
<button class="filter-tab" data-filter="new">需複習</button>
</div>
</div>
<div class="vocabulary-items">
${allWords.map(word => this.renderVocabularyItem(word)).join('')}
</div>
</div>
`;
}
renderVocabularyItem(word) {
const statusClass = word.status === 'learned' ? 'learned' :
word.status === 'learning' ? 'learning' : '';
return `
<div class="vocabulary-item" data-word-id="${word.id}">
<div class="word-info">
<div class="mastery-indicator ${statusClass}"></div>
<div class="word-text">
<span class="word-main">${word.word}</span>
<span class="word-definition">${word.definition}</span>
</div>
</div>
<div class="word-status">
<button class="play-btn" data-word="${word.word}"></button>
</div>
</div>
`;
}
bindEvents() {
// 手機版選單切換
const mobileMenuBtn = document.getElementById('mobileMenuBtn');
const sidebar = document.getElementById('sidebar');
if (mobileMenuBtn) {
mobileMenuBtn.addEventListener('click', () => {
this.isMobileMenuOpen = !this.isMobileMenuOpen;
sidebar?.classList.toggle('open');
});
}
// 點擊外部關閉側邊欄
document.addEventListener('click', (e) => {
if (window.innerWidth <= 1024 &&
sidebar && !sidebar.contains(e.target) &&
mobileMenuBtn && !mobileMenuBtn.contains(e.target)) {
this.isMobileMenuOpen = false;
sidebar.classList.remove('open');
}
});
// 學習模式切換
document.querySelectorAll('.mode-card').forEach(card => {
card.addEventListener('click', () => {
const mode = card.dataset.mode;
this.switchMode(mode);
});
});
// 詞彙卡片控制
this.bindFlashcardEvents();
// 詞彙清單互動
this.bindVocabularyListEvents();
// 響應式處理
window.addEventListener('resize', () => {
if (window.innerWidth > 1024) {
this.isMobileMenuOpen = false;
sidebar?.classList.remove('open');
}
});
}
bindFlashcardEvents() {
const flipCardBtn = document.getElementById('flipCardBtn');
const nextCardBtn = document.getElementById('nextCardBtn');
const playAudioBtn = document.getElementById('playAudioBtn');
if (flipCardBtn) {
flipCardBtn.addEventListener('click', () => {
this.flipCard();
});
}
if (nextCardBtn) {
nextCardBtn.addEventListener('click', () => {
this.nextCard();
});
}
if (playAudioBtn) {
playAudioBtn.addEventListener('click', () => {
this.playAudio();
});
}
// 難度按鈕
document.querySelectorAll('.difficulty-btn').forEach(btn => {
btn.addEventListener('click', () => {
const difficulty = btn.dataset.difficulty;
this.selectDifficulty(difficulty);
});
});
}
bindVocabularyListEvents() {
// 篩選標籤
document.querySelectorAll('.filter-tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.filter-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
const filter = tab.dataset.filter;
this.filterVocabulary(filter);
});
});
// 詞彙項目點擊
document.querySelectorAll('.vocabulary-item').forEach(item => {
item.addEventListener('click', (e) => {
if (!e.target.classList.contains('play-btn')) {
const wordId = item.dataset.wordId;
this.selectWord(wordId);
}
});
});
// 播放按鈕
document.querySelectorAll('.play-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const word = btn.dataset.word;
this.playWordAudio(word);
});
});
}
// Event Handlers
switchMode(mode) {
this.currentMode = mode;
// 更新UI
document.querySelectorAll('.mode-card').forEach(card => {
card.classList.toggle('active', card.dataset.mode === mode);
});
// 顯示對應的學習區域
const flashcardSection = document.getElementById('flashcardSection');
if (mode === 'flashcard') {
flashcardSection?.classList.remove('hidden');
} else {
flashcardSection?.classList.add('hidden');
if (mode === 'quiz') {
alert('詞彙測驗模式即將推出!');
} else if (mode === 'context') {
alert('情境學習模式即將推出!');
}
}
}
flipCard() {
this.isCardFlipped = !this.isCardFlipped;
// 更新UI
const definition = document.querySelector('.vocabulary-definition');
const example = document.querySelector('.vocabulary-example');
const difficultyButtons = document.querySelector('.difficulty-buttons');
const flipBtn = document.getElementById('flipCardBtn');
if (this.isCardFlipped) {
definition?.classList.remove('hidden');
example?.classList.remove('hidden');
difficultyButtons?.classList.remove('hidden');
if (flipBtn) flipBtn.textContent = '🔄 翻回';
} else {
definition?.classList.add('hidden');
example?.classList.add('hidden');
difficultyButtons?.classList.add('hidden');
if (flipBtn) flipBtn.textContent = '🔄 翻轉';
}
}
nextCard() {
// 重置翻轉狀態
this.isCardFlipped = false;
// 獲取下一個詞彙
const reviewQueue = this.state.getReviewQueue();
const newWords = this.state.getNewWords(1);
const nextWord = reviewQueue[0] || newWords[0];
if (nextWord) {
this.state.setCurrentWord(nextWord);
} else {
// 沒有更多詞彙時重新渲染
this.updateFlashcardSection();
}
}
selectDifficulty(difficulty) {
const currentWord = this.state.getCurrentWord();
if (currentWord) {
this.state.markWordReview(currentWord.id, difficulty);
this.nextCard();
}
}
selectWord(wordId) {
const words = this.state.getAllWords();
const word = words.find(w => w.id === wordId);
if (word) {
this.state.setCurrentWord(word);
this.switchMode('flashcard');
}
}
filterVocabulary(filter) {
const items = document.querySelectorAll('.vocabulary-item');
items.forEach(item => {
const wordId = item.dataset.wordId;
const words = this.state.getAllWords();
const word = words.find(w => w.id === wordId);
if (!word) return;
let show = true;
switch (filter) {
case 'learning':
show = word.status === 'learning';
break;
case 'learned':
show = word.status === 'learned';
break;
case 'new':
show = word.status === 'new';
break;
case 'all':
default:
show = true;
}
item.style.display = show ? 'flex' : 'none';
});
}
playAudio() {
const currentWord = this.state.getCurrentWord();
if (currentWord) {
this.playWordAudio(currentWord.word);
}
}
async playWordAudio(word) {
try {
console.log(`🔊 Playing pronunciation for: ${word}`);
await this.audioManager.speakWord(word);
} catch (error) {
console.error('Failed to play audio:', error);
// Fallback to alert for now
alert(`🔊 播放 "${word}" 的發音 (${error.message})`);
}
}
// State change handlers
onStateChange(event, data) {
switch (event) {
case 'currentWordChanged':
this.updateFlashcardSection();
break;
case 'progressUpdated':
this.updateProgressStats();
break;
case 'wordUpdated':
case 'wordAdded':
this.updateVocabularyList();
this.updateProgressStats();
break;
}
}
updateFlashcardSection() {
const flashcardSection = document.getElementById('flashcardSection');
if (flashcardSection) {
const currentWord = this.state.getCurrentWord();
flashcardSection.outerHTML = this.renderFlashcardSection(currentWord);
this.bindFlashcardEvents();
}
}
updateProgressStats() {
const progress = this.state.getProgress();
const statValues = document.querySelectorAll('.stat-value');
if (statValues.length >= 3) {
statValues[0].textContent = progress.learned || 0;
statValues[1].textContent = progress.todayNew || 0;
statValues[2].textContent = `${progress.masteryRate || 0}%`;
}
}
updateVocabularyList() {
const vocabularyItems = document.querySelector('.vocabulary-items');
if (vocabularyItems) {
const allWords = this.state.getAllWords();
vocabularyItems.innerHTML = allWords.map(word => this.renderVocabularyItem(word)).join('');
this.bindVocabularyListEvents();
}
}
destroy() {
if (this.unsubscribe) {
this.unsubscribe();
}
}
}

View File

@ -1,322 +0,0 @@
// Vocabulary State Management
export class VocabularyState {
#words = new Map();
#currentWord = null;
#currentMode = 'flashcard';
#currentScene = 'coffee';
#progress = {
learned: 0,
todayNew: 0,
masteryRate: 0
};
#listeners = new Set();
constructor() {
this.loadFromStorage();
this.initializeDefaultData();
}
// State getters
getCurrentWord() {
return this.#currentWord;
}
getCurrentMode() {
return this.#currentMode;
}
getCurrentScene() {
return this.#currentScene;
}
getProgress() {
return { ...this.#progress };
}
getAllWords() {
return Array.from(this.#words.values());
}
getWordsByStatus(status) {
return this.getAllWords().filter(word => word.status === status);
}
// State setters
setCurrentWord(word) {
this.#currentWord = word;
this.notify('currentWordChanged', word);
}
setCurrentMode(mode) {
this.#currentMode = mode;
this.notify('modeChanged', mode);
}
setCurrentScene(scene) {
this.#currentScene = scene;
this.notify('sceneChanged', scene);
}
// Word management
addWord(wordData) {
const word = {
id: wordData.id || Date.now().toString(),
word: wordData.word,
phonetic: wordData.phonetic,
definition: wordData.definition,
example: wordData.example,
translation: wordData.translation,
status: wordData.status || 'new',
difficulty: wordData.difficulty || 'normal',
reviewCount: wordData.reviewCount || 0,
correctCount: wordData.correctCount || 0,
lastReview: wordData.lastReview || null,
nextReview: wordData.nextReview || Date.now(),
createdAt: wordData.createdAt || Date.now(),
updatedAt: Date.now()
};
this.#words.set(word.id, word);
this.updateProgress();
this.saveToStorage();
this.notify('wordAdded', word);
return word;
}
updateWord(id, updates) {
const word = this.#words.get(id);
if (!word) return null;
const updatedWord = {
...word,
...updates,
updatedAt: Date.now()
};
this.#words.set(id, updatedWord);
this.updateProgress();
this.saveToStorage();
this.notify('wordUpdated', updatedWord);
return updatedWord;
}
deleteWord(id) {
const word = this.#words.get(id);
if (!word) return false;
this.#words.delete(id);
this.updateProgress();
this.saveToStorage();
this.notify('wordDeleted', word);
return true;
}
// Review logic
markWordReview(id, difficulty) {
const word = this.#words.get(id);
if (!word) return null;
const now = Date.now();
let nextReviewDelay;
// Spaced repetition algorithm
switch (difficulty) {
case 'easy':
nextReviewDelay = 3 * 24 * 60 * 60 * 1000; // 3 days
break;
case 'hard':
nextReviewDelay = 10 * 60 * 1000; // 10 minutes
break;
case 'normal':
default:
nextReviewDelay = 24 * 60 * 60 * 1000; // 1 day
}
const updates = {
reviewCount: word.reviewCount + 1,
lastReview: now,
nextReview: now + nextReviewDelay,
difficulty: difficulty
};
// Update status based on review performance
if (difficulty === 'easy' && word.reviewCount >= 2) {
updates.status = 'learned';
} else if (word.status === 'new') {
updates.status = 'learning';
}
return this.updateWord(id, updates);
}
markWordCorrect(id) {
const word = this.#words.get(id);
if (!word) return null;
return this.updateWord(id, {
correctCount: word.correctCount + 1
});
}
// Queue management
getReviewQueue() {
const now = Date.now();
return this.getAllWords()
.filter(word => word.nextReview <= now)
.sort((a, b) => a.nextReview - b.nextReview);
}
getNewWords(limit = 10) {
return this.getAllWords()
.filter(word => word.status === 'new')
.slice(0, limit);
}
// Progress calculation
updateProgress() {
const allWords = this.getAllWords();
const learned = allWords.filter(word => word.status === 'learned').length;
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayTimestamp = today.getTime();
const todayNew = allWords.filter(word =>
word.createdAt >= todayTimestamp
).length;
const totalReviews = allWords.reduce((sum, word) => sum + word.reviewCount, 0);
const totalCorrect = allWords.reduce((sum, word) => sum + word.correctCount, 0);
const masteryRate = totalReviews > 0 ? Math.round((totalCorrect / totalReviews) * 100) : 0;
this.#progress = {
learned,
todayNew,
masteryRate
};
this.notify('progressUpdated', this.#progress);
}
// Event system
subscribe(listener) {
this.#listeners.add(listener);
return () => this.#listeners.delete(listener);
}
notify(event, data) {
this.#listeners.forEach(listener => {
try {
listener(event, data);
} catch (error) {
console.error('State listener error:', error);
}
});
}
// Persistence
saveToStorage() {
try {
const data = {
words: Array.from(this.#words.values()),
currentMode: this.#currentMode,
currentScene: this.#currentScene,
currentWordId: this.#currentWord?.id || null,
progress: this.#progress
};
localStorage.setItem('dramaling-vocabulary', JSON.stringify(data));
} catch (error) {
console.error('Failed to save to storage:', error);
}
}
loadFromStorage() {
try {
const data = localStorage.getItem('dramaling-vocabulary');
if (!data) return;
const parsed = JSON.parse(data);
// Restore words
if (parsed.words) {
this.#words.clear();
parsed.words.forEach(word => {
this.#words.set(word.id, word);
});
}
// Restore current state
this.#currentMode = parsed.currentMode || 'flashcard';
this.#currentScene = parsed.currentScene || 'coffee';
this.#progress = parsed.progress || this.#progress;
// Restore current word
if (parsed.currentWordId) {
this.#currentWord = this.#words.get(parsed.currentWordId);
}
this.updateProgress();
} catch (error) {
console.error('Failed to load from storage:', error);
}
}
// Initialize with sample data
initializeDefaultData() {
if (this.#words.size === 0) {
const sampleWords = [
{
word: 'confidence',
phonetic: '/ˈkɒnfɪdəns/',
definition: '信心;自信心;把握',
example: 'She spoke with great confidence during the presentation.',
translation: '她在簡報中表現出很大的自信。',
status: 'learning'
},
{
word: 'presentation',
phonetic: '/ˌprezənˈteɪʃən/',
definition: '簡報;呈現',
example: 'The presentation was very informative.',
translation: '這個簡報很有資訊性。',
status: 'learning'
},
{
word: 'colleague',
phonetic: '/ˈkɒliːɡ/',
definition: '同事;同僚',
example: 'I discussed the project with my colleague.',
translation: '我和同事討論了這個專案。',
status: 'new'
},
{
word: 'opportunity',
phonetic: '/ˌɒpəˈtjuːnɪti/',
definition: '機會;時機',
example: 'This is a great opportunity to learn.',
translation: '這是一個學習的好機會。',
status: 'learned'
},
{
word: 'achievement',
phonetic: '/əˈtʃiːvmənt/',
definition: '成就;成績',
example: 'Graduating from university was a great achievement.',
translation: '從大學畢業是一個偉大的成就。',
status: 'learning'
}
];
sampleWords.forEach(wordData => this.addWord(wordData));
// Set first word as current
if (this.#words.size > 0) {
this.#currentWord = this.getAllWords()[0];
}
}
}
}

View File

@ -0,0 +1,210 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'home',
component: () => import('@/views/HomeView.vue'),
meta: {
title: 'Drama Ling - 戲劇式語言學習',
requiresAuth: false
}
},
{
path: '/auth',
component: () => import('@/layouts/AuthLayout.vue'),
children: [
{
path: 'login',
name: 'login',
component: () => import('@/views/auth/LoginView.vue'),
meta: {
title: '登入 - Drama Ling',
requiresAuth: false
}
},
{
path: 'register',
name: 'register',
component: () => import('@/views/auth/RegisterView.vue'),
meta: {
title: '註冊 - Drama Ling',
requiresAuth: false
}
},
{
path: 'forgot-password',
name: 'forgot-password',
component: () => import('@/views/auth/ForgotPasswordView.vue'),
meta: {
title: '忘記密碼 - Drama Ling',
requiresAuth: false
}
}
]
},
{
path: '/learning',
component: () => import('@/layouts/AppLayout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'learning',
component: () => import('@/views/learning/LearningHomeView.vue'),
meta: {
title: '學習地圖 - Drama Ling'
}
},
{
path: 'vocabulary',
name: 'vocabulary',
component: () => import('@/views/learning/VocabularyView.vue'),
meta: {
title: '詞彙學習 - Drama Ling'
}
},
{
path: 'dialogue/:id',
name: 'dialogue',
component: () => import('@/views/learning/DialogueView.vue'),
meta: {
title: '對話練習 - Drama Ling'
},
props: true
},
{
path: 'roleplay/:id',
name: 'roleplay',
component: () => import('@/views/learning/RoleplayView.vue'),
meta: {
title: '角色扮演 - Drama Ling'
},
props: true
},
{
path: 'pronunciation/:id',
name: 'pronunciation',
component: () => import('@/views/learning/PronunciationView.vue'),
meta: {
title: '發音練習 - Drama Ling'
},
props: true
}
]
},
{
path: '/profile',
component: () => import('@/layouts/AppLayout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'profile',
component: () => import('@/views/profile/ProfileView.vue'),
meta: {
title: '個人檔案 - Drama Ling'
}
},
{
path: 'progress',
name: 'progress',
component: () => import('@/views/profile/ProgressView.vue'),
meta: {
title: '學習進度 - Drama Ling'
}
},
{
path: 'settings',
name: 'settings',
component: () => import('@/views/profile/SettingsView.vue'),
meta: {
title: '設定 - Drama Ling'
}
}
]
},
{
path: '/shop',
component: () => import('@/layouts/AppLayout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'shop',
component: () => import('@/views/shop/ShopView.vue'),
meta: {
title: '商店 - Drama Ling'
}
},
{
path: 'subscription',
name: 'subscription',
component: () => import('@/views/shop/SubscriptionView.vue'),
meta: {
title: '訂閱方案 - Drama Ling'
}
}
]
},
{
path: '/offline',
name: 'offline',
component: () => import('@/views/OfflineView.vue'),
meta: {
title: '離線模式 - Drama Ling',
requiresAuth: false
}
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('@/views/NotFoundView.vue'),
meta: {
title: '頁面未找到 - Drama Ling',
requiresAuth: false
}
}
]
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition
}
if (to.hash) {
return { el: to.hash }
}
return { top: 0 }
}
})
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore()
// 設定頁面標題
if (to.meta.title) {
document.title = to.meta.title as string
}
// 檢查認證需求
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
// 保存目標路徑,登入後跳轉
authStore.setRedirectPath(to.fullPath)
next({ name: 'login' })
return
}
// 已登入用戶訪問登入頁面時跳轉到首頁
if ((to.name === 'login' || to.name === 'register') && authStore.isAuthenticated) {
next({ name: 'learning' })
return
}
next()
})
export default router

View File

@ -0,0 +1,20 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'home',
component: () => import('@/views/HomeView.vue'),
meta: {
title: 'Drama Ling - 戲劇式語言學習',
description: 'AI驅動的情境式語言學習應用'
}
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router

View File

@ -0,0 +1,277 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'home',
component: () => import('@/views/HomeView.vue'),
meta: {
title: 'Drama Ling - 戲劇式語言學習',
requiresAuth: false
}
},
{
path: '/auth',
component: () => import('@/layouts/AuthLayout.vue'),
children: [
{
path: 'login',
name: 'login',
component: () => import('@/views/auth/LoginView.vue'),
meta: {
title: '登入 - Drama Ling',
requiresAuth: false
}
},
{
path: 'register',
name: 'register',
component: () => import('@/views/auth/RegisterView.vue'),
meta: {
title: '註冊 - Drama Ling',
requiresAuth: false
}
},
{
path: 'forgot-password',
name: 'forgot-password',
component: () => import('@/views/auth/ForgotPasswordView.vue'),
meta: {
title: '忘記密碼 - Drama Ling',
requiresAuth: false
}
}
]
},
{
path: '/learning',
component: () => import('@/layouts/AppLayout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'learning',
component: () => import('@/views/learning/LearningHomeView.vue'),
meta: {
title: '學習地圖 - Drama Ling'
}
},
{
path: 'vocabulary',
name: 'vocabulary',
component: () => import('@/views/learning/VocabularyViewSimple.vue'),
meta: {
title: '詞彙學習 - Drama Ling'
}
},
{
path: 'vocabulary-native',
name: 'vocabulary-native',
component: () => import('@/views/learning/VocabularyViewNative.vue'),
meta: {
title: '詞彙學習 (原生樣式) - Drama Ling'
}
},
{
path: 'vocabulary/practice',
name: 'vocabulary-practice',
component: () => import('@/views/learning/VocabularyPracticeView.vue'),
meta: {
title: '詞彙練習 - Drama Ling'
}
},
{
path: 'vocabulary/choice-practice',
name: 'vocabulary-choice-practice',
component: () => import('@/views/learning/VocabularyChoicePracticeView.vue'),
meta: {
title: '選擇題練習 - Drama Ling'
}
},
{
path: 'vocabulary/choice-results/:sessionId',
name: 'vocabulary-choice-results',
component: () => import('@/views/learning/VocabularyChoiceResultsView.vue'),
meta: {
title: '練習結果 - Drama Ling'
},
props: true
},
{
path: 'vocabulary/matching-practice',
name: 'vocabulary-matching-practice',
component: () => import('@/views/learning/VocabularyMatchingPracticeView.vue'),
meta: {
title: '圖片匹配練習 - Drama Ling'
}
},
{
path: 'vocabulary/reorganize-practice',
name: 'vocabulary-reorganize-practice',
component: () => import('@/views/learning/VocabularyReorganizePracticeView.vue'),
meta: {
title: '句子重組練習 - Drama Ling'
}
},
{
path: 'vocabulary/analytics',
name: 'vocabulary-analytics',
component: () => import('@/views/learning/VocabularyAnalyticsDashboard.vue'),
meta: {
title: '詞彙學習分析儀表板 - Drama Ling'
}
},
{
path: 'vocabulary/review',
name: 'vocabulary-review',
component: () => import('@/views/learning/VocabularyReviewMain.vue'),
meta: {
title: '智能複習系統 - Drama Ling'
}
},
{
path: 'dialogue/:id',
name: 'dialogue',
component: () => import('@/views/learning/DialogueView.vue'),
meta: {
title: '對話練習 - Drama Ling'
},
props: true
},
{
path: 'roleplay/:id',
name: 'roleplay',
component: () => import('@/views/learning/RoleplayView.vue'),
meta: {
title: '角色扮演 - Drama Ling'
},
props: true
},
{
path: 'pronunciation/:id',
name: 'pronunciation',
component: () => import('@/views/learning/PronunciationView.vue'),
meta: {
title: '發音練習 - Drama Ling'
},
props: true
}
]
},
{
path: '/profile',
component: () => import('@/layouts/AppLayout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'profile',
component: () => import('@/views/profile/ProfileView.vue'),
meta: {
title: '個人檔案 - Drama Ling'
}
},
{
path: 'progress',
name: 'progress',
component: () => import('@/views/profile/ProgressView.vue'),
meta: {
title: '學習進度 - Drama Ling'
}
},
{
path: 'settings',
name: 'settings',
component: () => import('@/views/profile/SettingsView.vue'),
meta: {
title: '設定 - Drama Ling'
}
}
]
},
{
path: '/shop',
component: () => import('@/layouts/AppLayout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'shop',
component: () => import('@/views/shop/ShopView.vue'),
meta: {
title: '商店 - Drama Ling'
}
},
{
path: 'subscription',
name: 'subscription',
component: () => import('@/views/shop/SubscriptionView.vue'),
meta: {
title: '訂閱方案 - Drama Ling'
}
}
]
},
{
path: '/offline',
name: 'offline',
component: () => import('@/views/OfflineView.vue'),
meta: {
title: '離線模式 - Drama Ling',
requiresAuth: false
}
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('@/views/NotFoundView.vue'),
meta: {
title: '頁面未找到 - Drama Ling',
requiresAuth: false
}
}
]
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition
}
if (to.hash) {
return { el: to.hash }
}
return { top: 0 }
}
})
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore()
// 設定頁面標題
if (to.meta.title) {
document.title = to.meta.title as string
}
console.log('Route to:', to.path, 'Auth:', authStore.isAuthenticated)
// 檢查認證需求
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
// 保存目標路徑,登入後跳轉
authStore.setRedirectPath(to.fullPath)
next({ name: 'login' })
return
}
// 已登入用戶訪問登入頁面時跳轉到首頁
if ((to.name === 'login' || to.name === 'register') && authStore.isAuthenticated) {
next({ name: 'learning' })
return
}
next()
})
export default router

325
apps/web/src/stores/auth.ts Normal file
View File

@ -0,0 +1,325 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { User } from '@/types/user'
export interface LoginCredentials {
email: string
password: string
rememberMe?: boolean
}
export interface RegisterData {
email: string
password: string
confirmPassword: string
username: string
agreeToTerms: boolean
}
export const useAuthStore = defineStore('auth', () => {
// 狀態
const user = ref<User | null>(null)
const token = ref<string | null>(null)
const refreshToken = ref<string | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
const redirectPath = ref<string>('/')
// 計算屬性
const isAuthenticated = computed(() => !!token.value && !!user.value)
const userDisplayName = computed(() => user.value?.username || user.value?.email || '')
// 動作
const login = async (credentials: LoginCredentials) => {
isLoading.value = true
error.value = null
try {
// 開發模式:允許特定測試帳戶直接登入
if (import.meta.env.DEV &&
credentials.email === 'test@dramaling.com' &&
credentials.password === 'test123') {
// 模擬API響應延遲
await new Promise(resolve => setTimeout(resolve, 1000))
// 設定測試用戶資料
token.value = 'dev_token_' + Date.now()
refreshToken.value = 'dev_refresh_token_' + Date.now()
user.value = {
id: 'dev_user_1',
email: 'test@dramaling.com',
username: 'TestUser',
displayName: '測試用戶',
avatar: '/images/default-avatar.png',
verified: true,
subscription: {
plan: 'premium',
status: 'active',
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString()
},
preferences: {
language: 'zh-TW',
theme: 'light',
notifications: true
},
createdAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
updatedAt: new Date().toISOString()
}
return { success: true }
}
// 實際API調用生產模式或非測試帳戶
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(credentials)
})
if (!response.ok) {
// 開發模式下提供有用的錯誤信息
if (import.meta.env.DEV) {
throw new Error('API未連接請使用測試帳戶:\n📧 test@dramaling.com\n🔑 test123')
}
throw new Error('登入失敗,請檢查帳戶資訊')
}
const data = await response.json()
// 設定認證資料
token.value = data.token
refreshToken.value = data.refreshToken
user.value = data.user
return { success: true }
} catch (err) {
error.value = err instanceof Error ? err.message : '登入失敗'
return { success: false, error: error.value }
} finally {
isLoading.value = false
}
}
const register = async (data: RegisterData) => {
isLoading.value = true
error.value = null
try {
// TODO: 實際API調用
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
})
if (!response.ok) {
throw new Error('註冊失敗')
}
const responseData = await response.json()
// 自動登入
token.value = responseData.token
refreshToken.value = responseData.refreshToken
user.value = responseData.user
return { success: true }
} catch (err) {
error.value = err instanceof Error ? err.message : '註冊失敗'
return { success: false, error: error.value }
} finally {
isLoading.value = false
}
}
const logout = async () => {
isLoading.value = true
try {
// TODO: 呼叫登出API
if (token.value) {
await fetch('/api/auth/logout', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token.value}`
}
})
}
} catch (err) {
console.error('登出API錯誤:', err)
} finally {
// 清除本地狀態
user.value = null
token.value = null
refreshToken.value = null
error.value = null
isLoading.value = false
redirectPath.value = '/'
}
}
const refreshTokenAction = async () => {
if (!refreshToken.value) {
return false
}
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
refreshToken: refreshToken.value
})
})
if (!response.ok) {
throw new Error('Token刷新失敗')
}
const data = await response.json()
token.value = data.token
return true
} catch (err) {
console.error('Token刷新錯誤:', err)
await logout()
return false
}
}
const updateProfile = async (profileData: Partial<User>) => {
if (!user.value) return { success: false, error: '用戶未登入' }
isLoading.value = true
error.value = null
try {
const response = await fetch('/api/user/profile', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token.value}`
},
body: JSON.stringify(profileData)
})
if (!response.ok) {
throw new Error('更新檔案失敗')
}
const updatedUser = await response.json()
user.value = { ...user.value, ...updatedUser }
return { success: true }
} catch (err) {
error.value = err instanceof Error ? err.message : '更新檔案失敗'
return { success: false, error: error.value }
} finally {
isLoading.value = false
}
}
const forgotPassword = async (email: string) => {
isLoading.value = true
error.value = null
try {
const response = await fetch('/api/auth/forgot-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ email })
})
if (!response.ok) {
throw new Error('發送重設密碼郵件失敗')
}
return { success: true }
} catch (err) {
error.value = err instanceof Error ? err.message : '發送重設密碼郵件失敗'
return { success: false, error: error.value }
} finally {
isLoading.value = false
}
}
const resetPassword = async (token: string, password: string) => {
isLoading.value = true
error.value = null
try {
const response = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ token, password })
})
if (!response.ok) {
throw new Error('重設密碼失敗')
}
return { success: true }
} catch (err) {
error.value = err instanceof Error ? err.message : '重設密碼失敗'
return { success: false, error: error.value }
} finally {
isLoading.value = false
}
}
const setRedirectPath = (path: string) => {
redirectPath.value = path
}
const clearError = () => {
error.value = null
}
const initialize = async () => {
// 應用啟動時檢查是否有有效的token
if (token.value && !user.value) {
await refreshTokenAction()
}
}
return {
// 狀態
user,
token,
refreshToken,
isLoading,
error,
redirectPath,
// 計算屬性
isAuthenticated,
userDisplayName,
// 動作
login,
register,
logout,
refreshTokenAction,
updateProfile,
forgotPassword,
resetPassword,
setRedirectPath,
clearError,
initialize
}
}, {
persist: {
paths: ['user', 'token', 'refreshToken', 'redirectPath']
}
})

View File

@ -0,0 +1,16 @@
import { createPinia } from 'pinia'
import { createPersistedState } from 'pinia-plugin-persistedstate'
const pinia = createPinia()
// 配置持久化插件
pinia.use(createPersistedState({
storage: localStorage,
auto: true
}))
export { pinia }
export * from './auth'
export * from './user'
export * from './learning'
export * from './ui'

View File

@ -0,0 +1,364 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Lesson, Course, LearningSession, VocabularyCard } from '@/types/learning'
export const useLearningStore = defineStore('learning', () => {
// 狀態
const currentCourse = ref<Course | null>(null)
const currentLesson = ref<Lesson | null>(null)
const currentSession = ref<LearningSession | null>(null)
const vocabulary = ref<VocabularyCard[]>([])
const courses = ref<Course[]>([])
const recentLessons = ref<Lesson[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
// 學習狀態
const sessionStartTime = ref<Date | null>(null)
const currentQuestionIndex = ref(0)
const sessionAnswers = ref<any[]>([])
const sessionScore = ref(0)
// 計算屬性
const availableCourses = computed(() => {
return courses.value.filter(course => course.isAvailable)
})
const completedCourses = computed(() => {
return courses.value.filter(course => course.progress === 100)
})
const inProgressCourses = computed(() => {
return courses.value.filter(course => course.progress > 0 && course.progress < 100)
})
const currentProgress = computed(() => {
if (!currentCourse.value) return 0
return currentCourse.value.progress || 0
})
const sessionProgress = computed(() => {
if (!currentSession.value?.questions?.length) return 0
return (currentQuestionIndex.value / currentSession.value.questions.length) * 100
})
const masteredVocabulary = computed(() => {
return vocabulary.value.filter(card => card.masteryLevel >= 5)
})
const reviewDueVocabulary = computed(() => {
const now = new Date()
return vocabulary.value.filter(card =>
card.nextReviewDate && new Date(card.nextReviewDate) <= now
)
})
// 動作
const fetchCourses = async () => {
isLoading.value = true
error.value = null
try {
const response = await fetch('/api/learning/courses', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
})
if (!response.ok) {
throw new Error('獲取課程失敗')
}
courses.value = await response.json()
} catch (err) {
error.value = err instanceof Error ? err.message : '獲取課程失敗'
} finally {
isLoading.value = false
}
}
const fetchCourse = async (courseId: string) => {
isLoading.value = true
try {
const response = await fetch(`/api/learning/courses/${courseId}`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
})
if (!response.ok) {
throw new Error('獲取課程詳情失敗')
}
currentCourse.value = await response.json()
} catch (err) {
error.value = err instanceof Error ? err.message : '獲取課程詳情失敗'
} finally {
isLoading.value = false
}
}
const startLesson = async (lessonId: string) => {
isLoading.value = true
sessionStartTime.value = new Date()
currentQuestionIndex.value = 0
sessionAnswers.value = []
sessionScore.value = 0
try {
const response = await fetch(`/api/learning/lessons/${lessonId}/start`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
})
if (!response.ok) {
throw new Error('開始課程失敗')
}
const sessionData = await response.json()
currentLesson.value = sessionData.lesson
currentSession.value = sessionData.session
return { success: true }
} catch (err) {
error.value = err instanceof Error ? err.message : '開始課程失敗'
return { success: false, error: error.value }
} finally {
isLoading.value = false
}
}
const submitAnswer = async (answer: any) => {
if (!currentSession.value) return { success: false }
try {
const response = await fetch(`/api/learning/sessions/${currentSession.value.id}/answer`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({
questionIndex: currentQuestionIndex.value,
answer
})
})
if (!response.ok) {
throw new Error('提交答案失敗')
}
const result = await response.json()
// 更新本地狀態
sessionAnswers.value.push({
questionIndex: currentQuestionIndex.value,
answer,
isCorrect: result.isCorrect,
feedback: result.feedback
})
if (result.isCorrect) {
sessionScore.value += result.points || 10
}
// 移動到下一題
currentQuestionIndex.value += 1
return {
success: true,
isCorrect: result.isCorrect,
feedback: result.feedback,
points: result.points
}
} catch (err) {
error.value = err instanceof Error ? err.message : '提交答案失敗'
return { success: false, error: error.value }
}
}
const completeSession = async () => {
if (!currentSession.value || !sessionStartTime.value) return { success: false }
const duration = Date.now() - sessionStartTime.value.getTime()
try {
const response = await fetch(`/api/learning/sessions/${currentSession.value.id}/complete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({
duration,
score: sessionScore.value,
answers: sessionAnswers.value
})
})
if (!response.ok) {
throw new Error('完成學習階段失敗')
}
const result = await response.json()
// 重設狀態
currentSession.value = null
sessionStartTime.value = null
currentQuestionIndex.value = 0
sessionAnswers.value = []
sessionScore.value = 0
return {
success: true,
result
}
} catch (err) {
error.value = err instanceof Error ? err.message : '完成學習階段失敗'
return { success: false, error: error.value }
}
}
const fetchVocabulary = async () => {
try {
const response = await fetch('/api/learning/vocabulary', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
})
if (!response.ok) {
throw new Error('獲取詞彙失敗')
}
vocabulary.value = await response.json()
} catch (err) {
console.error('獲取詞彙錯誤:', err)
}
}
const updateVocabularyMastery = async (cardId: string, isCorrect: boolean) => {
try {
const response = await fetch(`/api/learning/vocabulary/${cardId}/review`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({ isCorrect })
})
if (!response.ok) {
throw new Error('更新詞彙熟練度失敗')
}
const updatedCard = await response.json()
// 更新本地狀態
const index = vocabulary.value.findIndex(card => card.id === cardId)
if (index !== -1) {
vocabulary.value[index] = updatedCard
}
return { success: true }
} catch (err) {
console.error('更新詞彙熟練度錯誤:', err)
return { success: false }
}
}
const pauseSession = () => {
if (currentSession.value) {
currentSession.value.isPaused = true
}
}
const resumeSession = () => {
if (currentSession.value) {
currentSession.value.isPaused = false
}
}
const skipQuestion = () => {
if (currentSession.value && currentQuestionIndex.value < currentSession.value.questions.length - 1) {
currentQuestionIndex.value += 1
// 記錄跳過的答案
sessionAnswers.value.push({
questionIndex: currentQuestionIndex.value - 1,
answer: null,
isCorrect: false,
skipped: true
})
}
}
const fetchRecentLessons = async () => {
try {
const response = await fetch('/api/learning/recent-lessons', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
})
if (!response.ok) {
throw new Error('獲取最近課程失敗')
}
recentLessons.value = await response.json()
} catch (err) {
console.error('獲取最近課程錯誤:', err)
}
}
const resetCurrentSession = () => {
currentSession.value = null
currentLesson.value = null
sessionStartTime.value = null
currentQuestionIndex.value = 0
sessionAnswers.value = []
sessionScore.value = 0
}
return {
// 狀態
currentCourse,
currentLesson,
currentSession,
vocabulary,
courses,
recentLessons,
isLoading,
error,
sessionStartTime,
currentQuestionIndex,
sessionAnswers,
sessionScore,
// 計算屬性
availableCourses,
completedCourses,
inProgressCourses,
currentProgress,
sessionProgress,
masteredVocabulary,
reviewDueVocabulary,
// 動作
fetchCourses,
fetchCourse,
startLesson,
submitAnswer,
completeSession,
fetchVocabulary,
updateVocabularyMastery,
pauseSession,
resumeSession,
skipQuestion,
fetchRecentLessons,
resetCurrentSession
}
})

View File

@ -0,0 +1,422 @@
// Practice System Store (練習系統狀態管理)
// 依據 practice.ts 類型定義和 function-specs 練習模式需求
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type {
PracticeType,
PracticeSession,
PracticeQuestion,
ChoiceQuestion,
MatchingQuestion,
ReorganizeQuestion,
UserAnswer,
PracticeResult,
PracticeConfig,
ResponseTimer,
PracticeStats,
WrongQuestionRecord
} from '@/types/practice'
export const usePracticeStore = defineStore('practice', () => {
// 狀態定義
const currentSession = ref<PracticeSession | null>(null)
const practiceConfig = ref<PracticeConfig>({
questionsPerSession: 10,
timePerQuestion: 30,
enableLives: true,
maxLives: 3,
enableHints: false,
enableAudio: true,
autoAdvance: false,
showCorrectAnswer: true,
difficulty: 3
})
const responseTimer = ref<ResponseTimer>({
startTime: 0,
endTime: undefined,
isRunning: false
})
const practiceStats = ref<PracticeStats>({
totalSessions: 0,
totalQuestions: 0,
correctAnswers: 0,
averageScore: 0,
averageResponseTime: 0,
fastestResponseTime: 0,
longestStreak: 0,
currentStreak: 0,
masteredVocabulary: 0,
practiceTimeToday: 0,
practiceTimeThisWeek: 0
})
const wrongQuestions = ref<WrongQuestionRecord[]>([])
// Getters
const isSessionActive = computed(() => currentSession.value !== null && !currentSession.value.isCompleted)
const currentQuestion = computed(() => {
if (!currentSession.value) return null
return currentSession.value.questions[currentSession.value.currentQuestionIndex] || null
})
const sessionProgress = computed(() => {
if (!currentSession.value) return 0
return (currentSession.value.currentQuestionIndex / currentSession.value.totalQuestions) * 100
})
const canContinue = computed(() => {
if (!currentSession.value) return false
return currentSession.value.lives > 0
})
// Actions - 會話管理
function startPracticeSession(vocabularyIds: string[], practiceType: PracticeType): string {
const sessionId = generateSessionId()
const questions = generateQuestions(vocabularyIds, practiceType)
currentSession.value = {
id: sessionId,
vocabularyIds,
practiceType,
questions,
answers: [],
startTime: new Date(),
isCompleted: false,
currentQuestionIndex: 0,
score: 0,
totalQuestions: questions.length,
correctAnswers: 0,
averageResponseTime: 0,
lives: practiceConfig.value.maxLives,
maxLives: practiceConfig.value.maxLives
}
return sessionId
}
function submitAnswer(answer: Omit<UserAnswer, 'submittedAt' | 'isCorrect'>): boolean {
if (!currentSession.value || !currentQuestion.value) return false
stopTimer()
const isCorrect = validateAnswer(currentQuestion.value, answer)
const completeAnswer: UserAnswer = {
...answer,
submittedAt: new Date(),
isCorrect
}
currentSession.value.answers.push(completeAnswer)
if (isCorrect) {
currentSession.value.correctAnswers++
practiceStats.value.currentStreak++
if (practiceStats.value.currentStreak > practiceStats.value.longestStreak) {
practiceStats.value.longestStreak = practiceStats.value.currentStreak
}
} else {
practiceStats.value.currentStreak = 0
if (practiceConfig.value.enableLives) {
currentSession.value.lives--
}
recordWrongQuestion(currentQuestion.value, currentSession.value.practiceType)
}
updateSessionStats()
return isCorrect
}
function nextQuestion(): boolean {
if (!currentSession.value) return false
currentSession.value.currentQuestionIndex++
if (currentSession.value.currentQuestionIndex >= currentSession.value.totalQuestions) {
completeSession()
return false
}
if (!canContinue.value) {
completeSession()
return false
}
return true
}
function completeSession(): PracticeResult | null {
if (!currentSession.value) return null
currentSession.value.isCompleted = true
currentSession.value.endTime = new Date()
const result = generatePracticeResult(currentSession.value)
updateGlobalStats(result)
return result
}
// Actions - 計時器管理
function startTimer(): void {
responseTimer.value = {
startTime: performance.now(),
endTime: undefined,
isRunning: true
}
}
function stopTimer(): number {
if (!responseTimer.value.isRunning) return 0
responseTimer.value.endTime = performance.now()
responseTimer.value.isRunning = false
return responseTimer.value.endTime - responseTimer.value.startTime
}
function resetTimer(): void {
responseTimer.value = {
startTime: 0,
endTime: undefined,
isRunning: false
}
}
// 工具函數
function generateSessionId(): string {
return `practice_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
}
function generateQuestions(vocabularyIds: string[], practiceType: PracticeType): (ChoiceQuestion | MatchingQuestion | ReorganizeQuestion)[] {
// TODO: 實際實現需要從後端API獲取詞彙數據並生成對應練習題
// 這裡返回模擬數據結構
return vocabularyIds.map((vocabId, index) => {
const baseQuestion = {
id: `q_${index}`,
vocabularyId: vocabId,
vocabularyWord: `word_${index}`,
timeLimit: practiceConfig.value.timePerQuestion,
difficulty: practiceConfig.value.difficulty,
content: `Practice question for ${vocabId}`
}
switch (practiceType) {
case 'choice':
return {
...baseQuestion,
type: 'definition' as const,
options: [
{ id: 'opt1', text: 'Option 1', isCorrect: true },
{ id: 'opt2', text: 'Option 2', isCorrect: false },
{ id: 'opt3', text: 'Option 3', isCorrect: false },
{ id: 'opt4', text: 'Option 4', isCorrect: false }
],
correctAnswerId: 'opt1'
} as ChoiceQuestion
case 'matching':
return {
...baseQuestion,
type: 'image' as const,
images: [
{ id: 'img1', url: '/mock-image1.jpg', vocabularyId: vocabId }
],
correctPairs: [
{ imageId: 'img1', vocabularyId: vocabId }
]
} as MatchingQuestion
case 'reorganize':
return {
...baseQuestion,
type: 'example' as const,
sentence: 'This is a test sentence',
words: [
{ id: 'w1', text: 'This' },
{ id: 'w2', text: 'is' },
{ id: 'w3', text: 'a' },
{ id: 'w4', text: 'test' },
{ id: 'w5', text: 'sentence' }
],
correctOrder: ['w1', 'w2', 'w3', 'w4', 'w5']
} as ReorganizeQuestion
default:
throw new Error(`Unknown practice type: ${practiceType}`)
}
})
}
function validateAnswer(question: ChoiceQuestion | MatchingQuestion | ReorganizeQuestion, answer: Omit<UserAnswer, 'submittedAt' | 'isCorrect'>): boolean {
if ('options' in question && 'correctAnswerId' in question) {
// 選擇題
return answer.selectedOptionId === question.correctAnswerId
} else if ('correctPairs' in question && question.correctPairs) {
// 圖片匹配
if (!answer.selectedPairs) return false
return question.correctPairs.every(correctPair =>
answer.selectedPairs!.some(selectedPair =>
selectedPair.imageId === correctPair.imageId &&
selectedPair.vocabularyId === correctPair.vocabularyId
)
)
} else if ('correctOrder' in question && question.correctOrder) {
// 句子重組
if (!answer.wordOrder) return false
return JSON.stringify(answer.wordOrder) === JSON.stringify(question.correctOrder)
}
return false
}
function updateSessionStats(): void {
if (!currentSession.value) return
const totalResponseTime = currentSession.value.answers.reduce((sum, answer) => sum + answer.responseTime, 0)
currentSession.value.averageResponseTime = totalResponseTime / currentSession.value.answers.length
currentSession.value.score = (currentSession.value.correctAnswers / currentSession.value.answers.length) * 100
}
function generatePracticeResult(session: PracticeSession): PracticeResult {
const accuracy = (session.correctAnswers / session.totalQuestions) * 100
const overallScore = Math.max(0, accuracy - (session.maxLives - session.lives) * 10)
return {
sessionId: session.id,
overallScore,
masteryLevel: determineMasteryLevel(overallScore),
recognitionScore: accuracy,
comprehensionScore: accuracy * 0.9, // 略低於識別分數
applicationScore: accuracy * 0.8, // 最低分數
responseSpeedScore: calculateSpeedScore(session.averageResponseTime),
averageResponseTime: session.averageResponseTime,
accuracy,
weaknessAnalysis: generateWeaknessAnalysis(session),
improvementSuggestions: generateImprovementSuggestions(session),
nextPracticeTopics: [],
experienceGained: Math.floor(overallScore / 10),
rewards: generateRewards(session)
}
}
function determineMasteryLevel(score: number): 'initial' | 'familiar' | 'application' | 'mastered' {
if (score >= 90) return 'mastered'
if (score >= 75) return 'application'
if (score >= 60) return 'familiar'
return 'initial'
}
function calculateSpeedScore(avgResponseTime: number): number {
// 基於平均反應時間計算速度分數 (越快分數越高)
const targetTime = practiceConfig.value.timePerQuestion * 1000 * 0.5 // 50%目標時間
return Math.max(0, Math.min(100, 100 - ((avgResponseTime - targetTime) / targetTime) * 50))
}
function generateWeaknessAnalysis(session: PracticeSession): string {
const wrongAnswers = session.answers.filter(answer => !answer.isCorrect)
if (wrongAnswers.length === 0) return '表現優秀,沒有明顯弱點'
return `需要加強練習,錯誤率: ${(wrongAnswers.length / session.totalQuestions * 100).toFixed(1)}%`
}
function generateImprovementSuggestions(session: PracticeSession): string[] {
const suggestions = []
const accuracy = (session.correctAnswers / session.totalQuestions) * 100
if (accuracy < 60) {
suggestions.push('建議重複學習基礎詞彙')
}
if (session.averageResponseTime > practiceConfig.value.timePerQuestion * 1000 * 0.8) {
suggestions.push('加強記憶練習以提升反應速度')
}
if (session.lives < session.maxLives) {
suggestions.push('注意仔細閱讀題目,避免粗心錯誤')
}
return suggestions
}
function generateRewards(session: PracticeSession): Array<{type: 'experience' | 'diamond' | 'achievement' | 'life', amount: number, description: string}> {
const rewards = []
const score = (session.correctAnswers / session.totalQuestions) * 100
rewards.push({
type: 'experience' as const,
amount: Math.floor(score / 10),
description: `獲得 ${Math.floor(score / 10)} 經驗值`
})
if (score >= 90) {
rewards.push({
type: 'diamond' as const,
amount: 10,
description: '完美表現獎勵鑽石'
})
}
return rewards
}
function recordWrongQuestion(question: PracticeQuestion, practiceType: PracticeType): void {
const existingRecord = wrongQuestions.value.find(
record => record.vocabularyId === question.vocabularyId && record.practiceType === practiceType
)
if (existingRecord) {
existingRecord.wrongCount++
existingRecord.lastWrongDate = new Date()
existingRecord.isResolved = false
} else {
wrongQuestions.value.push({
questionId: question.id,
vocabularyId: question.vocabularyId,
practiceType,
wrongCount: 1,
lastWrongDate: new Date(),
isResolved: false
})
}
}
function updateGlobalStats(result: PracticeResult): void {
practiceStats.value.totalSessions++
practiceStats.value.totalQuestions += currentSession.value?.totalQuestions || 0
practiceStats.value.correctAnswers += currentSession.value?.correctAnswers || 0
practiceStats.value.averageScore = (practiceStats.value.averageScore * (practiceStats.value.totalSessions - 1) + result.overallScore) / practiceStats.value.totalSessions
practiceStats.value.averageResponseTime = (practiceStats.value.averageResponseTime * (practiceStats.value.totalSessions - 1) + result.averageResponseTime) / practiceStats.value.totalSessions
if (result.averageResponseTime < practiceStats.value.fastestResponseTime || practiceStats.value.fastestResponseTime === 0) {
practiceStats.value.fastestResponseTime = result.averageResponseTime
}
}
// 配置管理
function updateConfig(config: Partial<PracticeConfig>): void {
practiceConfig.value = { ...practiceConfig.value, ...config }
}
function resetSession(): void {
currentSession.value = null
resetTimer()
}
return {
// 狀態
currentSession,
practiceConfig,
responseTimer,
practiceStats,
wrongQuestions,
// Getters
isSessionActive,
currentQuestion,
sessionProgress,
canContinue,
// Actions
startPracticeSession,
submitAnswer,
nextQuestion,
completeSession,
startTimer,
stopTimer,
resetTimer,
updateConfig,
resetSession
}
})

View File

@ -0,0 +1,349 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type {
VocabularyReviewData,
ReviewSession,
ReviewResponse,
WeaknessPattern
} from '@/utils/spacedRepetition'
import {
SpacedRepetitionAlgorithm,
createDefaultVocabularyReviewData
} from '@/utils/spacedRepetition'
export interface LearningPlan {
date: string
vocabulary: VocabularyReviewData[]
totalCount: number
estimatedTime: number // 分鐘
}
export interface ReviewStats {
todayCompleted: number
todayTotal: number
weeklyStreak: number
totalMastered: number
averageAccuracy: number
improvementTrend: number
nextReviewTime: Date | null
}
export const useReviewStore = defineStore('review', () => {
// 狀態
const vocabularyReviewData = ref<Map<string, VocabularyReviewData>>(new Map())
const reviewHistory = ref<ReviewSession[]>([])
const currentReviewSession = ref<ReviewSession | null>(null)
const learningPlan = ref<Map<string, LearningPlan>>(new Map())
const isLoading = ref(false)
const algorithm = new SpacedRepetitionAlgorithm()
// 計算屬性
const todaysReviewVocabulary = computed(() => {
const allVocabulary = Array.from(vocabularyReviewData.value.values())
return SpacedRepetitionAlgorithm.getTodaysReviewVocabulary(allVocabulary)
})
const reviewStats = computed((): ReviewStats => {
const todayTotal = todaysReviewVocabulary.value.length
const todayCompleted = reviewHistory.value.filter(session => {
const today = new Date()
today.setHours(0, 0, 0, 0)
const sessionDate = new Date(session.startTime)
sessionDate.setHours(0, 0, 0, 0)
return sessionDate.getTime() === today.getTime()
}).length
const allVocabulary = Array.from(vocabularyReviewData.value.values())
const totalMastered = allVocabulary.filter(v => v.masteryLevel >= 80).length
const efficiency = SpacedRepetitionAlgorithm.analyzeLearningEfficiency(reviewHistory.value)
// 計算連續學習天數
const weeklyStreak = calculateWeeklyStreak()
// 下次複習時間
const nextReviewTime = getNextReviewTime()
return {
todayCompleted,
todayTotal,
weeklyStreak,
totalMastered,
averageAccuracy: efficiency.averageAccuracy,
improvementTrend: efficiency.improvementTrend,
nextReviewTime
}
})
const urgentReviewVocabulary = computed(() => {
const today = new Date()
return todaysReviewVocabulary.value.filter(vocab => {
const overdueDays = Math.floor((today.getTime() - vocab.nextReviewDate.getTime()) / (24 * 60 * 60 * 1000))
return overdueDays > 2 // 過期超過2天視為緊急
})
})
const weaknessAnalysis = computed(() => {
const allPatterns = new Map<string, { severity: number, frequency: number }>()
Array.from(vocabularyReviewData.value.values()).forEach(vocab => {
vocab.weaknessPatterns.forEach(pattern => {
const existing = allPatterns.get(pattern.type) || { severity: 0, frequency: 0 }
allPatterns.set(pattern.type, {
severity: Math.max(existing.severity, pattern.severity),
frequency: existing.frequency + pattern.frequency
})
})
})
return Array.from(allPatterns.entries())
.map(([type, data]) => ({
type,
severity: data.severity,
frequency: data.frequency,
score: data.severity * Math.log(data.frequency + 1)
}))
.sort((a, b) => b.score - a.score)
.slice(0, 5) // 前5個最嚴重的薄弱點
})
// 方法
const initializeVocabularyReviewData = (vocabularyIds: string[]) => {
vocabularyIds.forEach(id => {
if (!vocabularyReviewData.value.has(id)) {
vocabularyReviewData.value.set(id, createDefaultVocabularyReviewData(id))
}
})
}
const startReviewSession = (vocabularyIds: string[]): string => {
const sessionId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
currentReviewSession.value = {
vocabularyId: vocabularyIds[0], // 如果是批量複習,這裡需要調整
startTime: new Date(),
responses: [],
overallAccuracy: 0,
averageResponseTime: 0
}
return sessionId
}
const addReviewResponse = (response: Omit<ReviewResponse, 'timestamp'>) => {
if (!currentReviewSession.value) {
throw new Error('沒有活躍的複習會話')
}
const fullResponse: ReviewResponse = {
...response,
timestamp: new Date()
}
currentReviewSession.value.responses.push(fullResponse)
// 更新會話統計
updateSessionStats()
}
const completeReviewSession = () => {
if (!currentReviewSession.value) {
throw new Error('沒有活躍的複習會話')
}
currentReviewSession.value.endTime = new Date()
// 更新複習數據
const reviewData = vocabularyReviewData.value.get(currentReviewSession.value.vocabularyId)
if (reviewData) {
const updatedData = algorithm.calculateNextReview(reviewData, currentReviewSession.value)
vocabularyReviewData.value.set(reviewData.id, updatedData)
}
// 保存到歷史記錄
reviewHistory.value.push({ ...currentReviewSession.value })
// 清空當前會話
currentReviewSession.value = null
// 重新生成學習計劃
generateLearningPlan()
}
const updateSessionStats = () => {
if (!currentReviewSession.value) return
const responses = currentReviewSession.value.responses
const correctCount = responses.filter(r => r.isCorrect).length
const totalResponseTime = responses.reduce((sum, r) => sum + r.responseTime, 0)
currentReviewSession.value.overallAccuracy = responses.length > 0 ? correctCount / responses.length : 0
currentReviewSession.value.averageResponseTime = responses.length > 0 ? totalResponseTime / responses.length : 0
}
const generateLearningPlan = (daysAhead: number = 7) => {
const allVocabulary = Array.from(vocabularyReviewData.value.values())
const planMap = SpacedRepetitionAlgorithm.generateLearningPlan(allVocabulary, daysAhead)
learningPlan.value.clear()
planMap.forEach((vocabularyList, date) => {
const estimatedTime = vocabularyList.length * 2 // 每個詞彙平均2分鐘
learningPlan.value.set(date, {
date,
vocabulary: vocabularyList,
totalCount: vocabularyList.length,
estimatedTime
})
})
}
const calculateWeeklyStreak = (): number => {
if (reviewHistory.value.length === 0) return 0
const today = new Date()
let streak = 0
// 從今天開始往前檢查
for (let i = 0; i < 7; i++) {
const checkDate = new Date(today)
checkDate.setDate(today.getDate() - i)
checkDate.setHours(0, 0, 0, 0)
const nextDay = new Date(checkDate)
nextDay.setDate(checkDate.getDate() + 1)
const hasReviewOnDate = reviewHistory.value.some(session => {
const sessionDate = new Date(session.startTime)
return sessionDate >= checkDate && sessionDate < nextDay
})
if (hasReviewOnDate) {
streak++
} else if (i > 0) { // 今天沒複習不算打斷,其他天沒複習就算打斷
break
}
}
return streak
}
const getNextReviewTime = (): Date | null => {
const allVocabulary = Array.from(vocabularyReviewData.value.values())
if (allVocabulary.length === 0) return null
const nextReviews = allVocabulary
.filter(v => v.nextReviewDate > new Date())
.sort((a, b) => a.nextReviewDate.getTime() - b.nextReviewDate.getTime())
return nextReviews.length > 0 ? nextReviews[0].nextReviewDate : null
}
const getVocabularyReviewData = (vocabularyId: string): VocabularyReviewData | null => {
return vocabularyReviewData.value.get(vocabularyId) || null
}
const updateVocabularyReviewData = (data: VocabularyReviewData) => {
vocabularyReviewData.value.set(data.id, data)
}
const resetVocabularyProgress = (vocabularyId: string) => {
const defaultData = createDefaultVocabularyReviewData(vocabularyId)
vocabularyReviewData.value.set(vocabularyId, defaultData)
}
const getPersonalizedRecommendations = (): string[] => {
const recommendations: string[] = []
const stats = reviewStats.value
// 基於統計數據生成建議
if (stats.averageAccuracy < 0.7) {
recommendations.push('建議放慢學習節奏,專注於理解而不是數量')
}
if (stats.weeklyStreak === 0) {
recommendations.push('建立每日複習習慣即使只複習5個詞彙也有幫助')
}
if (urgentReviewVocabulary.value.length > 10) {
recommendations.push('有較多詞彙需要緊急複習,建議優先處理過期詞彙')
}
if (stats.improvementTrend < 0) {
recommendations.push('學習效果有下降趨勢,建議調整學習策略或休息一下')
}
// 基於薄弱點生成建議
const topWeakness = weaknessAnalysis.value[0]
if (topWeakness) {
const weaknessRecommendations = {
spelling: '建議加強拼寫練習,可以嘗試手寫練習',
meaning: '建議多做詞義辨析練習,建立詞彙語義網絡',
pronunciation: '建議多聽音頻,模仿正確發音',
usage: '建議多閱讀例句,理解詞彙在不同語境中的用法',
grammar: '建議複習相關語法規則,理解詞彙的語法功能'
}
recommendations.push(weaknessRecommendations[topWeakness.type as keyof typeof weaknessRecommendations])
}
return recommendations.slice(0, 3) // 最多返回3個建議
}
const exportReviewData = () => {
return {
vocabularyReviewData: Object.fromEntries(vocabularyReviewData.value),
reviewHistory: reviewHistory.value,
exportDate: new Date().toISOString()
}
}
const importReviewData = (data: any) => {
if (data.vocabularyReviewData) {
vocabularyReviewData.value = new Map(Object.entries(data.vocabularyReviewData))
}
if (data.reviewHistory) {
reviewHistory.value = data.reviewHistory.map((session: any) => ({
...session,
startTime: new Date(session.startTime),
endTime: session.endTime ? new Date(session.endTime) : undefined,
responses: session.responses.map((response: any) => ({
...response,
timestamp: new Date(response.timestamp)
}))
}))
}
generateLearningPlan()
}
// 初始化
generateLearningPlan()
return {
// 狀態
vocabularyReviewData,
reviewHistory,
currentReviewSession,
learningPlan,
isLoading,
// 計算屬性
todaysReviewVocabulary,
reviewStats,
urgentReviewVocabulary,
weaknessAnalysis,
// 方法
initializeVocabularyReviewData,
startReviewSession,
addReviewResponse,
completeReviewSession,
generateLearningPlan,
getVocabularyReviewData,
updateVocabularyReviewData,
resetVocabularyProgress,
getPersonalizedRecommendations,
exportReviewData,
importReviewData
}
})

280
apps/web/src/stores/ui.ts Normal file
View File

@ -0,0 +1,280 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export interface Toast {
id: string
type: 'success' | 'error' | 'warning' | 'info'
title: string
message?: string
duration?: number
persistent?: boolean
}
export interface Modal {
id: string
component: any
props?: Record<string, any>
persistent?: boolean
}
export const useUIStore = defineStore('ui', () => {
// 狀態
const theme = ref<'light' | 'dark' | 'auto'>('auto')
const sidebarCollapsed = ref(false)
const mobileMenuOpen = ref(false)
const loading = ref(false)
const toasts = ref<Toast[]>([])
const modals = ref<Modal[]>([])
const currentModal = ref<Modal | null>(null)
// 頁面狀態
const pageTitle = ref('Drama Ling')
const breadcrumbs = ref<{ label: string; to?: string }[]>([])
const headerActions = ref<any[]>([])
// 響應式狀態
const isMobile = ref(false)
const screenWidth = ref(0)
const screenHeight = ref(0)
// 計算屬性
const isDarkMode = computed(() => {
if (theme.value === 'auto') {
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
return theme.value === 'dark'
})
const activeToasts = computed(() => {
return toasts.value.filter(toast => !toast.persistent || Date.now() - parseInt(toast.id) < (toast.duration || 5000))
})
// 動作
const setTheme = (newTheme: 'light' | 'dark' | 'auto') => {
theme.value = newTheme
// 應用主題到 HTML 元素
const html = document.documentElement
if (newTheme === 'auto') {
html.classList.remove('dark', 'light')
} else {
html.classList.remove('dark', 'light')
html.classList.add(newTheme)
}
}
const toggleSidebar = () => {
sidebarCollapsed.value = !sidebarCollapsed.value
}
const closeSidebar = () => {
sidebarCollapsed.value = true
}
const openSidebar = () => {
sidebarCollapsed.value = false
}
const toggleMobileMenu = () => {
mobileMenuOpen.value = !mobileMenuOpen.value
}
const closeMobileMenu = () => {
mobileMenuOpen.value = false
}
const setLoading = (isLoading: boolean) => {
loading.value = isLoading
}
const showToast = (toast: Omit<Toast, 'id'>) => {
const id = Date.now().toString()
const newToast: Toast = {
id,
duration: 5000,
persistent: false,
...toast
}
toasts.value.push(newToast)
// 自動移除 toast如果不是持久的
if (!newToast.persistent && newToast.duration) {
setTimeout(() => {
hideToast(id)
}, newToast.duration)
}
return id
}
const hideToast = (id: string) => {
const index = toasts.value.findIndex(toast => toast.id === id)
if (index > -1) {
toasts.value.splice(index, 1)
}
}
const clearToasts = () => {
toasts.value = []
}
const showSuccessToast = (title: string, message?: string) => {
return showToast({
type: 'success',
title,
message
})
}
const showErrorToast = (title: string, message?: string) => {
return showToast({
type: 'error',
title,
message,
duration: 8000
})
}
const showWarningToast = (title: string, message?: string) => {
return showToast({
type: 'warning',
title,
message
})
}
const showInfoToast = (title: string, message?: string) => {
return showToast({
type: 'info',
title,
message
})
}
const showModal = (modal: Omit<Modal, 'id'>) => {
const id = Date.now().toString()
const newModal: Modal = {
id,
persistent: false,
...modal
}
modals.value.push(newModal)
currentModal.value = newModal
return id
}
const hideModal = (id?: string) => {
if (id) {
const index = modals.value.findIndex(modal => modal.id === id)
if (index > -1) {
modals.value.splice(index, 1)
}
} else {
modals.value.pop()
}
currentModal.value = modals.value[modals.value.length - 1] || null
}
const clearModals = () => {
modals.value = []
currentModal.value = null
}
const setPageTitle = (title: string) => {
pageTitle.value = title
document.title = `${title} - Drama Ling`
}
const setBreadcrumbs = (crumbs: { label: string; to?: string }[]) => {
breadcrumbs.value = crumbs
}
const setHeaderActions = (actions: any[]) => {
headerActions.value = actions
}
const updateScreenSize = () => {
screenWidth.value = window.innerWidth
screenHeight.value = window.innerHeight
isMobile.value = window.innerWidth < 768
}
const initializeUI = () => {
// 設定初始主題
if (theme.value === 'auto') {
setTheme('auto')
}
// 監聽窗口大小變化
updateScreenSize()
window.addEventListener('resize', updateScreenSize)
// 監聽系統主題變化
if (theme.value === 'auto') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
mediaQuery.addListener(() => {
if (theme.value === 'auto') {
setTheme('auto')
}
})
}
}
const cleanup = () => {
window.removeEventListener('resize', updateScreenSize)
}
return {
// 狀態
theme,
sidebarCollapsed,
mobileMenuOpen,
loading,
toasts,
modals,
currentModal,
pageTitle,
breadcrumbs,
headerActions,
isMobile,
screenWidth,
screenHeight,
// 計算屬性
isDarkMode,
activeToasts,
// 動作
setTheme,
toggleSidebar,
closeSidebar,
openSidebar,
toggleMobileMenu,
closeMobileMenu,
setLoading,
showToast,
hideToast,
clearToasts,
showSuccessToast,
showErrorToast,
showWarningToast,
showInfoToast,
showModal,
hideModal,
clearModals,
setPageTitle,
setBreadcrumbs,
setHeaderActions,
updateScreenSize,
initializeUI,
cleanup
}
}, {
persist: {
paths: ['theme', 'sidebarCollapsed']
}
})

308
apps/web/src/stores/user.ts Normal file
View File

@ -0,0 +1,308 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { User, UserProgress, UserPreferences } from '@/types/user'
export const useUserStore = defineStore('user', () => {
// 狀態
const profile = ref<User | null>(null)
const progress = ref<UserProgress | null>(null)
const preferences = ref<UserPreferences>({
language: 'zh-TW',
theme: 'light',
notifications: {
email: true,
push: true,
dailyReminder: true,
achievementAlert: true
},
privacy: {
profileVisible: false,
progressVisible: false,
allowFriendRequests: true
},
learning: {
dailyGoal: 30,
difficultyLevel: 'intermediate',
preferredPracticeTime: 'evening',
voiceEnabled: true,
subtitlesEnabled: true
}
})
const achievements = ref<any[]>([])
const friends = ref<any[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
// 計算屬性
const totalLearningTime = computed(() => {
return progress.value?.totalLearningTime || 0
})
const currentLevel = computed(() => {
return progress.value?.currentLevel || 1
})
const experiencePoints = computed(() => {
return progress.value?.experiencePoints || 0
})
const streakDays = computed(() => {
return progress.value?.streakDays || 0
})
const completedLessons = computed(() => {
return progress.value?.completedLessons || 0
})
const unlockedAchievements = computed(() => {
return achievements.value.filter(achievement => achievement.unlocked)
})
const reviewDueVocabulary = computed(() => {
// 模擬待複習詞彙數據,實際應該從學習進度中計算
return []
})
// 動作
const fetchUserProfile = async () => {
isLoading.value = true
error.value = null
try {
const response = await fetch('/api/user/profile', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
})
if (!response.ok) {
throw new Error('獲取用戶資料失敗')
}
profile.value = await response.json()
} catch (err) {
error.value = err instanceof Error ? err.message : '獲取用戶資料失敗'
} finally {
isLoading.value = false
}
}
const fetchUserProgress = async () => {
isLoading.value = true
try {
const response = await fetch('/api/user/progress', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
})
if (!response.ok) {
throw new Error('獲取學習進度失敗')
}
progress.value = await response.json()
} catch (err) {
error.value = err instanceof Error ? err.message : '獲取學習進度失敗'
} finally {
isLoading.value = false
}
}
const updatePreferences = async (newPreferences: Partial<UserPreferences>) => {
isLoading.value = true
error.value = null
try {
const response = await fetch('/api/user/preferences', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify(newPreferences)
})
if (!response.ok) {
throw new Error('更新偏好設定失敗')
}
const updatedPreferences = await response.json()
preferences.value = { ...preferences.value, ...updatedPreferences }
return { success: true }
} catch (err) {
error.value = err instanceof Error ? err.message : '更新偏好設定失敗'
return { success: false, error: error.value }
} finally {
isLoading.value = false
}
}
const updateDailyGoal = async (goal: number) => {
const result = await updatePreferences({
learning: {
...preferences.value.learning,
dailyGoal: goal
}
})
return result
}
const addExperience = (points: number) => {
if (progress.value) {
progress.value.experiencePoints += points
// 檢查是否升級
const newLevel = Math.floor(progress.value.experiencePoints / 1000) + 1
if (newLevel > progress.value.currentLevel) {
progress.value.currentLevel = newLevel
// 觸發升級事件
return { levelUp: true, newLevel }
}
}
return { levelUp: false }
}
const incrementLearningTime = (minutes: number) => {
if (progress.value) {
progress.value.totalLearningTime += minutes
progress.value.lastLearningDate = new Date().toISOString()
}
}
const updateStreak = () => {
if (!progress.value) return
const today = new Date().toDateString()
const lastLearning = progress.value.lastLearningDate ?
new Date(progress.value.lastLearningDate).toDateString() : null
if (lastLearning === today) {
// 今天已經學習過了,不更新連擊
return
}
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
if (lastLearning === yesterday.toDateString()) {
// 昨天有學習,增加連擊
progress.value.streakDays += 1
} else if (lastLearning !== today) {
// 中斷連擊,重新開始
progress.value.streakDays = 1
}
progress.value.lastLearningDate = new Date().toISOString()
}
const fetchAchievements = async () => {
try {
const response = await fetch('/api/user/achievements', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
})
if (!response.ok) {
throw new Error('獲取成就失敗')
}
achievements.value = await response.json()
} catch (err) {
console.error('獲取成就錯誤:', err)
}
}
const unlockAchievement = async (achievementId: string) => {
try {
const response = await fetch(`/api/user/achievements/${achievementId}/unlock`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
})
if (!response.ok) {
throw new Error('解鎖成就失敗')
}
// 更新本地狀態
const achievement = achievements.value.find(a => a.id === achievementId)
if (achievement) {
achievement.unlocked = true
achievement.unlockedAt = new Date().toISOString()
}
return { success: true, achievement }
} catch (err) {
console.error('解鎖成就錯誤:', err)
return { success: false }
}
}
const fetchFriends = async () => {
try {
const response = await fetch('/api/user/friends', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
})
if (!response.ok) {
throw new Error('獲取朋友列表失敗')
}
friends.value = await response.json()
} catch (err) {
console.error('獲取朋友列表錯誤:', err)
}
}
const clearUserData = () => {
profile.value = null
progress.value = null
achievements.value = []
friends.value = []
error.value = null
}
return {
// 狀態
profile,
progress,
preferences,
achievements,
friends,
isLoading,
error,
// 計算屬性
totalLearningTime,
currentLevel,
experiencePoints,
streakDays,
completedLessons,
unlockedAchievements,
reviewDueVocabulary,
// 動作
fetchUserProfile,
fetchUserProgress,
updatePreferences,
updateDailyGoal,
addExperience,
incrementLearningTime,
updateStreak,
fetchAchievements,
unlockAchievement,
fetchFriends,
clearUserData
}
}, {
persist: {
paths: ['preferences']
}
})

View File

@ -0,0 +1,393 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type {
Vocabulary,
Exercise,
ExerciseSession,
ExerciseResult,
ExerciseType,
PracticeSettings,
VocabularyProgress,
LearningAnalytics
} from '@/types/vocabulary'
export const useVocabularyStore = defineStore('vocabulary', () => {
// 狀態
const vocabularies = ref<Vocabulary[]>([])
const currentVocabulary = ref<Vocabulary | null>(null)
const exercises = ref<Exercise[]>([])
const currentSession = ref<ExerciseSession | null>(null)
const sessionResults = ref<ExerciseResult[]>([])
const progress = ref<VocabularyProgress[]>([])
const analytics = ref<LearningAnalytics | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
// 練習設定
const practiceSettings = ref<PracticeSettings>({
exercise_type: 'multiple_choice_definition',
difficulty_levels: [1, 2, 3],
question_count: 10,
time_limit_per_question: undefined,
enable_hints: true,
enable_audio: true,
shuffle_options: true,
immediate_feedback: false
})
// 計算屬性
const currentExercises = computed(() => {
if (!currentSession.value) return []
return exercises.value.filter(ex =>
currentSession.value!.vocabulary_list.includes(ex.vocabulary_id) &&
ex.type === currentSession.value!.exercise_type
)
})
const sessionProgress = computed(() => {
if (!currentSession.value) return 0
return (currentSession.value.completed_questions / currentSession.value.total_questions) * 100
})
const sessionAccuracy = computed(() => {
if (!currentSession.value || currentSession.value.completed_questions === 0) return 0
return (currentSession.value.correct_answers / currentSession.value.completed_questions) * 100
})
const wordsForReview = computed(() => {
const today = new Date().toISOString().split('T')[0]
return progress.value.filter(p => p.next_review_date <= today)
})
const masteredWords = computed(() => {
return progress.value.filter(p => p.mastery_level >= 80)
})
const learningWords = computed(() => {
return progress.value.filter(p => p.mastery_level < 80 && p.mastery_level > 0)
})
const newWords = computed(() => {
const learnedIds = new Set(progress.value.map(p => p.vocabulary_id))
return vocabularies.value.filter(v => !learnedIds.has(v.id))
})
// 動作
const fetchVocabularies = async (filters?: {
difficulty?: number[]
category?: string
limit?: number
}) => {
isLoading.value = true
error.value = null
try {
// 模擬API調用 - 實際應該呼叫後端API
const mockVocabularies: Vocabulary[] = [
{
id: 'vocab_1',
word: 'abundant',
phonetic: '/əˈbʌndənt/',
definitions: [{
id: 'def_1',
part_of_speech: 'adjective',
definition: 'existing or available in large quantities',
chinese_translation: '豐富的,充裕的'
}],
examples: [{
id: 'ex_1',
sentence: 'The region has abundant natural resources.',
chinese_translation: '這個地區有豐富的自然資源。'
}],
difficulty_level: 3,
frequency_rank: 1250,
category: 'academic'
},
{
id: 'vocab_2',
word: 'achieve',
phonetic: '/əˈtʃiːv/',
definitions: [{
id: 'def_2',
part_of_speech: 'verb',
definition: 'successfully bring about or reach a desired objective',
chinese_translation: '達成,實現'
}],
examples: [{
id: 'ex_2',
sentence: 'She worked hard to achieve her goals.',
chinese_translation: '她努力工作以實現她的目標。'
}],
difficulty_level: 2,
frequency_rank: 850,
category: 'general'
}
]
vocabularies.value = mockVocabularies
} catch (err) {
error.value = err instanceof Error ? err.message : '載入詞彙失敗'
} finally {
isLoading.value = false
}
}
const fetchExercises = async (vocabularyIds: string[], exerciseType: ExerciseType) => {
isLoading.value = true
try {
// 模擬生成選擇題練習
const mockExercises: Exercise[] = vocabularyIds.map(vocabId => {
const vocab = vocabularies.value.find(v => v.id === vocabId)
if (!vocab) return null
return {
id: `exercise_${vocabId}_${exerciseType}`,
vocabulary_id: vocabId,
type: exerciseType,
question: exerciseType === 'multiple_choice_definition'
? `What does "${vocab.word}" mean?`
: `What is the Chinese translation of "${vocab.word}"?`,
options: [
{
id: 'opt_1',
text: vocab.definitions[0].chinese_translation,
is_correct: true
},
{
id: 'opt_2',
text: '錯誤選項1',
is_correct: false
},
{
id: 'opt_3',
text: '錯誤選項2',
is_correct: false
},
{
id: 'opt_4',
text: '錯誤選項3',
is_correct: false
}
],
correct_answer_id: 'opt_1',
difficulty_level: vocab.difficulty_level
}
}).filter(Boolean) as Exercise[]
exercises.value = mockExercises
} catch (err) {
error.value = err instanceof Error ? err.message : '載入練習失敗'
} finally {
isLoading.value = false
}
}
const startExerciseSession = async (vocabularyIds: string[], exerciseType: ExerciseType) => {
try {
await fetchExercises(vocabularyIds, exerciseType)
const session: ExerciseSession = {
id: `session_${Date.now()}`,
user_id: 'current_user', // 應該從auth store獲取
vocabulary_list: vocabularyIds,
exercise_type: exerciseType,
start_time: new Date().toISOString(),
total_questions: exercises.value.length,
completed_questions: 0,
correct_answers: 0,
incorrect_answers: 0,
skipped_questions: 0,
average_response_time: 0,
status: 'in_progress'
}
currentSession.value = session
sessionResults.value = []
return session
} catch (err) {
error.value = err instanceof Error ? err.message : '開始練習失敗'
throw err
}
}
const submitAnswer = async (exerciseId: string, selectedOptionId: string, responseTime: number) => {
if (!currentSession.value) return
const exercise = exercises.value.find(ex => ex.id === exerciseId)
if (!exercise) return
const isCorrect = exercise.correct_answer_id === selectedOptionId
const result: ExerciseResult = {
id: `result_${Date.now()}`,
session_id: currentSession.value.id,
vocabulary_id: exercise.vocabulary_id,
exercise_id: exerciseId,
user_answer_id: selectedOptionId,
is_correct: isCorrect,
response_time: responseTime,
timestamp: new Date().toISOString(),
hints_used: 0
}
sessionResults.value.push(result)
// 更新會話統計
currentSession.value.completed_questions++
if (isCorrect) {
currentSession.value.correct_answers++
} else {
currentSession.value.incorrect_answers++
}
// 更新平均反應時間
const totalResponseTime = sessionResults.value.reduce((sum, r) => sum + r.response_time, 0)
currentSession.value.average_response_time = totalResponseTime / sessionResults.value.length
// 檢查是否完成會話
if (currentSession.value.completed_questions >= currentSession.value.total_questions) {
await completeSession()
}
return result
}
const completeSession = async () => {
if (!currentSession.value) return
currentSession.value.end_time = new Date().toISOString()
currentSession.value.status = 'completed'
// 更新詞彙學習進度
for (const result of sessionResults.value) {
await updateVocabularyProgress(result.vocabulary_id, result.is_correct, result.response_time)
}
return currentSession.value
}
const updateVocabularyProgress = async (vocabularyId: string, isCorrect: boolean, responseTime: number) => {
let vocabProgress = progress.value.find(p => p.vocabulary_id === vocabularyId)
if (!vocabProgress) {
// 創建新的進度記錄
vocabProgress = {
user_id: 'current_user',
vocabulary_id: vocabularyId,
mastery_level: 0,
last_studied: new Date().toISOString(),
review_count: 0,
error_patterns: [],
next_review_date: new Date().toISOString(),
first_learned_date: new Date().toISOString(),
total_study_time: 0
}
progress.value.push(vocabProgress)
}
// 更新進度
vocabProgress.last_studied = new Date().toISOString()
vocabProgress.review_count++
vocabProgress.total_study_time += Math.ceil(responseTime / 1000)
// 根據正確性調整熟練度
if (isCorrect) {
vocabProgress.mastery_level = Math.min(100, vocabProgress.mastery_level + 10)
} else {
vocabProgress.mastery_level = Math.max(0, vocabProgress.mastery_level - 5)
}
// 計算下次複習時間(簡化的間隔重複算法)
const intervals = [1, 3, 7, 14, 30, 90] // 天數
const reviewLevel = Math.floor(vocabProgress.mastery_level / 20)
const nextInterval = intervals[Math.min(reviewLevel, intervals.length - 1)]
const nextReview = new Date()
nextReview.setDate(nextReview.getDate() + nextInterval)
vocabProgress.next_review_date = nextReview.toISOString().split('T')[0]
}
const updatePracticeSettings = (newSettings: Partial<PracticeSettings>) => {
practiceSettings.value = { ...practiceSettings.value, ...newSettings }
}
const resetCurrentSession = () => {
currentSession.value = null
sessionResults.value = []
exercises.value = []
}
const fetchAnalytics = async () => {
isLoading.value = true
try {
// 計算學習分析數據
const totalWords = progress.value.length
const totalStudyTime = progress.value.reduce((sum, p) => sum + p.total_study_time, 0)
const completedSessions = sessionResults.value.length > 0 ? 1 : 0
const correctAnswers = sessionResults.value.filter(r => r.is_correct).length
const totalAnswers = sessionResults.value.length
const mockAnalytics: LearningAnalytics = {
total_words_learned: totalWords,
total_study_time: totalStudyTime,
average_accuracy: totalAnswers > 0 ? (correctAnswers / totalAnswers) * 100 : 0,
streak_days: 1, // 模擬數據
words_due_for_review: wordsForReview.value.length,
mastery_distribution: {
beginner: progress.value.filter(p => p.mastery_level <= 25).length,
intermediate: progress.value.filter(p => p.mastery_level > 25 && p.mastery_level <= 50).length,
advanced: progress.value.filter(p => p.mastery_level > 50 && p.mastery_level <= 75).length,
mastered: progress.value.filter(p => p.mastery_level > 75).length
},
weekly_progress: [],
error_patterns: []
}
analytics.value = mockAnalytics
} catch (err) {
error.value = err instanceof Error ? err.message : '載入分析數據失敗'
} finally {
isLoading.value = false
}
}
return {
// 狀態
vocabularies,
currentVocabulary,
exercises,
currentSession,
sessionResults,
progress,
analytics,
practiceSettings,
isLoading,
error,
// 計算屬性
currentExercises,
sessionProgress,
sessionAccuracy,
wordsForReview,
masteredWords,
learningWords,
newWords,
// 動作
fetchVocabularies,
fetchExercises,
startExerciseSession,
submitAnswer,
completeSession,
updatePracticeSettings,
resetCurrentSession,
fetchAnalytics
}
}, {
persist: {
paths: ['practiceSettings', 'progress']
}
})

View File

@ -1,26 +0,0 @@
@import './variables.scss';
@import './vocabulary.scss';
// Reset and Base Styles
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
}
// Loading Spinner
.loading-spinner {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: var(--text-lg);
color: var(--text-secondary);
}

View File

@ -1,76 +0,0 @@
// Design System Variables
// Colors
:root {
// Primary Colors
--primary-teal: #00e5cc;
--secondary-purple: #8b5cf6;
// Background
--bg-primary: #f8fafc;
--bg-secondary: #f1f5f9;
--bg-card: #ffffff;
--bg-dark: #1e293b;
// Text Colors
--text-primary: #1e293b;
--text-secondary: #64748b;
--text-tertiary: #94a3b8;
// Status Colors
--success-green: #22c55e;
--warning-yellow: #fbbf24;
--error-red: #ef4444;
// UI Elements
--divider: #e2e8f0;
--border: #cbd5e1;
// Spacing
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-8: 2rem;
// Typography
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
--text-3xl: 1.875rem;
--text-4xl: 2.25rem;
--text-5xl: 3rem;
--text-6xl: 3.75rem;
// Border Radius
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--radius-xl: 0.75rem;
// Shadows
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
}
// Dark mode overrides
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--bg-card: #334155;
--text-primary: #f1f5f9;
--text-secondary: #cbd5e1;
--text-tertiary: #64748b;
--divider: #475569;
--border: #64748b;
}
}

View File

@ -1,577 +0,0 @@
// Vocabulary Learning Styles (從HTML原型移植)
.vocabulary-layout {
min-height: 100vh;
background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%);
display: flex;
}
// 側邊欄樣式
.sidebar {
width: 280px;
background: var(--bg-card);
border-right: 1px solid var(--divider);
display: flex;
flex-direction: column;
position: fixed;
height: 100vh;
z-index: 100;
}
.sidebar-header {
padding: var(--space-6);
border-bottom: 1px solid var(--divider);
}
.logo {
display: flex;
align-items: center;
gap: var(--space-3);
color: var(--text-primary);
text-decoration: none;
font-size: var(--text-xl);
font-weight: 700;
}
.sidebar-nav {
flex: 1;
padding: var(--space-6) 0;
overflow-y: auto;
}
.nav-section {
margin-bottom: var(--space-6);
}
.nav-section-title {
font-size: var(--text-xs);
font-weight: 600;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--space-3);
padding: 0 var(--space-6);
}
.nav-item {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-6);
color: var(--text-secondary);
text-decoration: none;
font-size: var(--text-sm);
font-weight: 500;
transition: all 0.2s ease;
border-left: 3px solid transparent;
&:hover {
background: rgba(0, 229, 204, 0.1);
color: var(--primary-teal);
}
&.active {
background: rgba(0, 229, 204, 0.15);
color: var(--primary-teal);
border-left-color: var(--primary-teal);
}
}
.nav-icon {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.sidebar-footer {
padding: var(--space-6);
border-top: 1px solid var(--divider);
}
.user-profile {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3);
background: var(--bg-secondary);
border-radius: var(--radius-lg);
}
.user-avatar {
width: 40px;
height: 40px;
background: var(--primary-teal);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
color: white;
}
.user-info {
flex: 1;
}
.user-name {
font-size: var(--text-sm);
font-weight: 600;
color: var(--text-primary);
}
.user-level {
font-size: var(--text-xs);
color: var(--text-secondary);
}
// 主內容區
.main-content {
flex: 1;
margin-left: 280px;
padding: var(--space-6);
overflow-y: auto;
}
.page-header {
margin-bottom: var(--space-8);
}
.header-section {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-6);
}
.header-text h1 {
font-size: var(--text-3xl);
font-weight: 700;
color: var(--text-primary);
margin-bottom: var(--space-2);
}
.header-text p {
font-size: var(--text-lg);
color: var(--text-secondary);
}
.header-stats {
display: flex;
gap: var(--space-6);
align-items: center;
}
.stat-item {
text-align: center;
}
.stat-value {
font-size: var(--text-2xl);
font-weight: 700;
color: var(--primary-teal);
display: block;
}
.stat-label {
font-size: var(--text-sm);
color: var(--text-secondary);
}
// 學習模式選擇
.mode-selector {
display: flex;
gap: var(--space-4);
margin-bottom: var(--space-8);
}
.mode-card {
flex: 1;
background: var(--bg-card);
border: 2px solid var(--divider);
border-radius: var(--radius-xl);
padding: var(--space-6);
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
border-color: var(--primary-teal);
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
&.active {
border-color: var(--primary-teal);
background: rgba(0, 229, 204, 0.1);
}
}
.mode-icon {
font-size: var(--text-4xl);
margin-bottom: var(--space-3);
}
.mode-title {
font-size: var(--text-lg);
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--space-2);
}
.mode-description {
font-size: var(--text-sm);
color: var(--text-secondary);
margin-bottom: var(--space-4);
}
.mode-progress {
display: flex;
justify-content: space-between;
font-size: var(--text-sm);
color: var(--text-tertiary);
}
// 詞彙卡片學習區
.vocabulary-section {
background: var(--bg-card);
border: 1px solid var(--divider);
border-radius: var(--radius-xl);
padding: var(--space-8);
margin-bottom: var(--space-8);
text-align: center;
&.hidden {
display: none;
}
}
.vocabulary-card {
max-width: 600px;
margin: 0 auto;
padding: var(--space-8);
position: relative;
}
.vocabulary-word {
font-size: var(--text-5xl);
font-weight: 700;
color: var(--text-primary);
margin-bottom: var(--space-4);
}
.vocabulary-phonetic {
font-size: var(--text-xl);
color: var(--text-secondary);
margin-bottom: var(--space-6);
}
.vocabulary-definition {
font-size: var(--text-lg);
color: var(--text-primary);
margin-bottom: var(--space-6);
line-height: 1.6;
&.hidden {
display: none;
}
}
.vocabulary-example {
background: var(--bg-secondary);
padding: var(--space-4);
border-radius: var(--radius-lg);
margin-bottom: var(--space-6);
font-style: italic;
color: var(--text-secondary);
&.hidden {
display: none;
}
}
.vocabulary-controls {
display: flex;
justify-content: center;
gap: var(--space-4);
margin-top: var(--space-8);
}
.control-btn {
padding: var(--space-3) var(--space-6);
border: 2px solid var(--divider);
border-radius: var(--radius-lg);
background: var(--bg-card);
color: var(--text-primary);
font-size: var(--text-base);
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: var(--space-2);
&:hover {
border-color: var(--primary-teal);
background: rgba(0, 229, 204, 0.1);
}
&.primary {
background: var(--primary-teal);
border-color: var(--primary-teal);
color: white;
&:hover {
background: #00b8a0;
}
}
}
.difficulty-buttons {
display: flex;
justify-content: center;
gap: var(--space-3);
margin-top: var(--space-6);
&.hidden {
display: none;
}
}
.difficulty-btn {
padding: var(--space-2) var(--space-4);
border: 1px solid var(--divider);
border-radius: var(--radius-md);
background: var(--bg-card);
color: var(--text-secondary);
font-size: var(--text-sm);
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: var(--bg-secondary);
}
&.easy {
border-color: var(--success-green);
color: var(--success-green);
}
&.hard {
border-color: var(--error-red);
color: var(--error-red);
}
}
.no-words-message {
padding: var(--space-8);
text-align: center;
h3 {
font-size: var(--text-2xl);
color: var(--text-primary);
margin-bottom: var(--space-4);
}
p {
font-size: var(--text-lg);
color: var(--text-secondary);
margin-bottom: var(--space-6);
}
}
// 詞彙清單
.vocabulary-list {
background: var(--bg-card);
border: 1px solid var(--divider);
border-radius: var(--radius-xl);
padding: var(--space-6);
}
.list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-6);
}
.list-title {
font-size: var(--text-xl);
font-weight: 600;
color: var(--text-primary);
}
.filter-tabs {
display: flex;
gap: var(--space-2);
}
.filter-tab {
padding: var(--space-2) var(--space-4);
border: 1px solid var(--divider);
border-radius: var(--radius-md);
background: var(--bg-secondary);
color: var(--text-secondary);
font-size: var(--text-sm);
cursor: pointer;
transition: all 0.2s ease;
&.active {
background: var(--primary-teal);
border-color: var(--primary-teal);
color: white;
}
}
.vocabulary-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4);
border: 1px solid var(--divider);
border-radius: var(--radius-lg);
margin-bottom: var(--space-3);
transition: all 0.2s ease;
cursor: pointer;
&:hover {
border-color: var(--primary-teal);
background: rgba(0, 229, 204, 0.05);
}
}
.word-info {
display: flex;
align-items: center;
gap: var(--space-4);
}
.word-text {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.word-main {
font-size: var(--text-lg);
font-weight: 600;
color: var(--text-primary);
}
.word-definition {
font-size: var(--text-sm);
color: var(--text-secondary);
}
.word-status {
display: flex;
align-items: center;
gap: var(--space-3);
}
.mastery-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-tertiary);
&.learned {
background: var(--success-green);
}
&.learning {
background: var(--warning-yellow);
}
}
.play-btn {
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: var(--primary-teal);
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #00b8a0;
transform: scale(1.1);
}
}
// 手機版選單按鈕
.mobile-menu-btn {
display: none;
position: fixed;
top: var(--space-4);
left: var(--space-4);
width: 48px;
height: 48px;
background: var(--primary-teal);
border: none;
border-radius: var(--radius-lg);
color: white;
font-size: var(--text-lg);
z-index: 101;
cursor: pointer;
}
// 響應式設計
@media (max-width: 1024px) {
.sidebar {
transform: translateX(-100%);
transition: transform 0.3s ease;
&.open {
transform: translateX(0);
}
}
.main-content {
margin-left: 0;
}
.mobile-menu-btn {
display: flex;
align-items: center;
justify-content: center;
}
}
@media (max-width: 768px) {
.main-content {
padding: var(--space-4);
}
.header-section {
flex-direction: column;
align-items: flex-start;
gap: var(--space-4);
}
.mode-selector {
flex-direction: column;
}
.vocabulary-card {
padding: var(--space-4);
}
.vocabulary-word {
font-size: var(--text-4xl);
}
.vocabulary-controls {
flex-wrap: wrap;
}
.difficulty-buttons {
flex-direction: column;
gap: var(--space-2);
}
}

View File

@ -0,0 +1,198 @@
export interface Course {
id: string
title: string
description: string
thumbnail?: string
level: 'beginner' | 'intermediate' | 'advanced'
language: string
targetLanguage: string
duration: number // 預估總時長(分鐘)
lessonsCount: number
progress: number // 0-100
isAvailable: boolean
isCompleted: boolean
enrolledAt?: string
completedAt?: string
lessons: Lesson[]
tags: string[]
difficulty: number // 1-10
rating: number
reviewsCount: number
}
export interface Lesson {
id: string
courseId: string
title: string
description: string
type: 'vocabulary' | 'dialogue' | 'grammar' | 'pronunciation' | 'roleplay' | 'review'
thumbnail?: string
duration: number // 預估時長(分鐘)
order: number
isUnlocked: boolean
isCompleted: boolean
progress: number // 0-100
completedAt?: string
score?: number
bestScore?: number
attempts: number
content: LessonContent
}
export interface LessonContent {
introduction?: {
text: string
audio?: string
video?: string
}
questions: Question[]
vocabulary?: VocabularyCard[]
dialogues?: DialogueScript[]
summary?: {
text: string
keyPoints: string[]
}
}
export interface Question {
id: string
type: 'multiple_choice' | 'fill_blank' | 'true_false' | 'matching' | 'ordering' | 'speaking' | 'listening'
question: string
questionAudio?: string
options?: string[]
correctAnswer: any
explanation?: string
points: number
hints?: string[]
media?: {
type: 'image' | 'audio' | 'video'
url: string
}
}
export interface VocabularyCard {
id: string
word: string
pronunciation: string
definition: string
translation: string
partOfSpeech: string
examples: {
sentence: string
translation: string
audio?: string
}[]
audio?: string
image?: string
masteryLevel: number // 0-5
lastReviewed?: string
nextReviewDate?: string
reviewCount: number
correctCount: number
tags: string[]
}
export interface DialogueScript {
id: string
title: string
scenario: string
participants: DialogueParticipant[]
lines: DialogueLine[]
vocabulary: string[] // vocabulary IDs
difficulty: number
duration: number
}
export interface DialogueParticipant {
id: string
name: string
avatar?: string
voice: string
role: string
}
export interface DialogueLine {
id: string
speakerId: string
text: string
translation: string
audio?: string
emotion?: string
speed?: 'slow' | 'normal' | 'fast'
order: number
}
export interface LearningSession {
id: string
lessonId: string
userId: string
startTime: string
endTime?: string
duration?: number // 秒
questions: Question[]
currentQuestionIndex: number
answers: SessionAnswer[]
score: number
totalPoints: number
accuracy: number
status: 'active' | 'paused' | 'completed' | 'abandoned'
isPaused?: boolean
}
export interface SessionAnswer {
questionId: string
questionIndex: number
answer: any
isCorrect: boolean
points: number
timeSpent: number // 秒
attempts: number
hintsUsed: number
skipped?: boolean
}
export interface PracticeSession {
id: string
type: 'vocabulary_review' | 'pronunciation' | 'dialogue_practice' | 'quick_review'
duration: number
questionsCount: number
correctAnswers: number
score: number
experienceGained: number
vocabularyReviewed: string[]
createdAt: string
}
export interface StudyPlan {
id: string
userId: string
title: string
description: string
targetLevel: string
targetDate: string
weeklyGoal: number // 分鐘
dailyGoal: number // 分鐘
courses: string[] // course IDs
schedule: StudySchedule[]
progress: number // 0-100
isActive: boolean
createdAt: string
updatedAt: string
}
export interface StudySchedule {
dayOfWeek: number // 0-6 (Sunday-Saturday)
timeSlots: {
startTime: string // HH:MM
duration: number // 分鐘
type: 'lesson' | 'review' | 'practice'
}[]
}
export interface LearningStreak {
currentStreak: number
longestStreak: number
lastStudyDate: string
streakGoal: number
isActive: boolean
}

View File

@ -0,0 +1,181 @@
// Practice System Types (依據 function-specs 練習模式定義)
// 基礎練習類型
export type PracticeType = 'choice' | 'matching' | 'reorganize'
// 題目類型 (依據mobile specs)
export type QuestionType = 'definition' | 'example' | 'image' | 'audio'
// 掌握度等級 (依據business logic)
export type MasteryLevel = 'initial' | 'familiar' | 'application' | 'mastered'
// 選擇題選項
export interface ChoiceOption {
id: string
text: string
isCorrect: boolean
}
// 基礎練習題目
export interface PracticeQuestion {
id: string
type: QuestionType
content: string
vocabularyId: string
vocabularyWord: string
timeLimit: number // 秒數 (15-60)
difficulty: number // 1-5
audioUrl?: string
imageUrl?: string
}
// 選擇題問題
export interface ChoiceQuestion extends PracticeQuestion {
options: ChoiceOption[]
correctAnswerId: string
}
// 圖片匹配題目
export interface MatchingQuestion extends PracticeQuestion {
images: MatchingImage[]
correctPairs: MatchingPair[]
}
export interface MatchingImage {
id: string
url: string
vocabularyId: string
}
export interface MatchingPair {
imageId: string
vocabularyId: string
}
// 句子重組題目
export interface ReorganizeQuestion extends PracticeQuestion {
sentence: string
words: ReorganizeWord[]
correctOrder: string[]
}
export interface ReorganizeWord {
id: string
text: string
position?: number
}
// 用戶答案
export interface UserAnswer {
questionId: string
selectedOptionId?: string // 選擇題
selectedPairs?: MatchingPair[] // 圖片匹配
wordOrder?: string[] // 句子重組
responseTime: number // 毫秒
isCorrect: boolean
submittedAt: Date
}
// 練習會話
export interface PracticeSession {
id: string
vocabularyIds: string[]
practiceType: PracticeType
questions: (ChoiceQuestion | MatchingQuestion | ReorganizeQuestion)[]
answers: UserAnswer[]
startTime: Date
endTime?: Date
isCompleted: boolean
currentQuestionIndex: number
score: number
totalQuestions: number
correctAnswers: number
averageResponseTime: number
lives: number // 命條系統
maxLives: number
}
// 練習結果分析
export interface PracticeResult {
sessionId: string
overallScore: number // 0-100
masteryLevel: MasteryLevel
recognitionScore: number // 識別能力 0-100
comprehensionScore: number // 理解能力 0-100
applicationScore: number // 應用能力 0-100
responseSpeedScore: number // 反應速度 0-100
averageResponseTime: number
accuracy: number // 正確率 0-100
weaknessAnalysis: string
improvementSuggestions: string[]
nextPracticeTopics: string[]
experienceGained: number
rewards?: PracticeReward[]
}
// 獎勵系統
export interface PracticeReward {
type: 'experience' | 'diamond' | 'achievement' | 'life'
amount: number
description: string
}
// 反應時間測量
export interface ResponseTimer {
startTime: number
endTime?: number
isRunning: boolean
}
// 練習統計
export interface PracticeStats {
totalSessions: number
totalQuestions: number
correctAnswers: number
averageScore: number
averageResponseTime: number
fastestResponseTime: number
longestStreak: number
currentStreak: number
masteredVocabulary: number
practiceTimeToday: number // 分鐘
practiceTimeThisWeek: number
}
// 錯題本
export interface WrongQuestionRecord {
questionId: string
vocabularyId: string
practiceType: PracticeType
wrongCount: number
lastWrongDate: Date
isResolved: boolean
notes?: string
}
// 練習配置
export interface PracticeConfig {
questionsPerSession: number // 5-20
timePerQuestion: number // 15-60秒
enableLives: boolean
maxLives: number
enableHints: boolean
enableAudio: boolean
autoAdvance: boolean
showCorrectAnswer: boolean
difficulty: number // 1-5
}
// 練習進度
export interface PracticeProgress {
vocabularyId: string
choicePracticeCompleted: boolean
matchingPracticeCompleted: boolean
reorganizePracticeCompleted: boolean
overallProgress: number // 0-100
lastPracticeDate: Date
nextReviewDate: Date
masteryLevel: MasteryLevel
practiceCount: number
errorCount: number
}

122
apps/web/src/types/user.ts Normal file
View File

@ -0,0 +1,122 @@
export interface User {
id: string
email: string
username: string
displayName?: string
avatar?: string
firstName?: string
lastName?: string
dateOfBirth?: string
phoneNumber?: string
country?: string
nativeLanguage?: string
targetLanguage?: string
createdAt: string
updatedAt: string
verified?: boolean
emailVerified?: boolean
isActive?: boolean
subscription?: {
plan: 'free' | 'premium' | 'unlimited'
status: 'active' | 'inactive' | 'expired' | 'cancelled'
expiresAt?: string
}
preferences?: {
language: string
theme: 'light' | 'dark' | 'auto'
notifications: boolean
}
}
export interface UserProgress {
userId: string
currentLevel: number
experiencePoints: number
totalLearningTime: number // 分鐘
streakDays: number
longestStreak: number
completedLessons: number
completedCourses: number
lastLearningDate?: string
dailyGoalMet: boolean
weeklyGoalProgress: number
monthlyGoalProgress: number
accuracy: number
vocabularyMastered: number
certificatesEarned: number
}
export interface UserPreferences {
language: string
theme: 'light' | 'dark' | 'auto'
notifications: {
email: boolean
push: boolean
dailyReminder: boolean
achievementAlert: boolean
}
privacy: {
profileVisible: boolean
progressVisible: boolean
allowFriendRequests: boolean
}
learning: {
dailyGoal: number // 分鐘
difficultyLevel: 'beginner' | 'intermediate' | 'advanced'
preferredPracticeTime: 'morning' | 'afternoon' | 'evening' | 'night'
voiceEnabled: boolean
subtitlesEnabled: boolean
}
}
export interface UserStats {
totalStudyTime: number
averageSessionLength: number
lessonsCompleted: number
vocabularyLearned: number
streakDays: number
accuracy: number
level: number
experiencePoints: number
}
export interface Achievement {
id: string
title: string
description: string
icon: string
category: 'progress' | 'streak' | 'vocabulary' | 'accuracy' | 'time' | 'special'
requirement: {
type: string
value: number
}
unlocked: boolean
unlockedAt?: string
points: number
}
export interface Friend {
id: string
username: string
avatar?: string
level: number
streakDays: number
status: 'online' | 'offline' | 'learning'
friendSince: string
}
export interface Leaderboard {
period: 'daily' | 'weekly' | 'monthly' | 'allTime'
entries: LeaderboardEntry[]
}
export interface LeaderboardEntry {
rank: number
user: {
id: string
username: string
avatar?: string
}
score: number
change: number // 排名變化
}

View File

@ -0,0 +1,138 @@
// 詞彙相關的型別定義
export interface Vocabulary {
id: string
word: string
phonetic: string
definitions: VocabularyDefinition[]
examples: VocabularyExample[]
difficulty_level: 1 | 2 | 3 | 4 | 5
frequency_rank: number
audio_url?: string
image_url?: string
category: string
}
export interface VocabularyDefinition {
id: string
part_of_speech: 'noun' | 'verb' | 'adjective' | 'adverb' | 'preposition' | 'conjunction' | 'interjection'
definition: string
chinese_translation: string
}
export interface VocabularyExample {
id: string
sentence: string
chinese_translation: string
audio_url?: string
}
export interface VocabularyProgress {
user_id: string
vocabulary_id: string
mastery_level: number // 0-100
last_studied: string
review_count: number
error_patterns: string[]
next_review_date: string
first_learned_date: string
total_study_time: number // in seconds
}
export interface Exercise {
id: string
vocabulary_id: string
type: ExerciseType
question: string
options: ExerciseOption[]
correct_answer_id: string
explanation?: string
difficulty_level: 1 | 2 | 3 | 4 | 5
}
export interface ExerciseOption {
id: string
text: string
is_correct: boolean
}
export type ExerciseType =
| 'multiple_choice_definition'
| 'multiple_choice_translation'
| 'multiple_choice_synonym'
| 'multiple_choice_usage'
| 'image_matching'
| 'sentence_completion'
| 'sentence_reorganize'
export interface ExerciseSession {
id: string
user_id: string
vocabulary_list: string[]
exercise_type: ExerciseType
start_time: string
end_time?: string
total_questions: number
completed_questions: number
correct_answers: number
incorrect_answers: number
skipped_questions: number
average_response_time: number
status: 'in_progress' | 'completed' | 'abandoned'
}
export interface ExerciseResult {
id: string
session_id: string
vocabulary_id: string
exercise_id: string
user_answer_id: string
is_correct: boolean
response_time: number // in milliseconds
timestamp: string
hints_used: number
}
export interface PracticeSettings {
exercise_type: ExerciseType
difficulty_levels: number[]
question_count: number
time_limit_per_question?: number // in seconds
enable_hints: boolean
enable_audio: boolean
shuffle_options: boolean
immediate_feedback: boolean
}
export interface ReviewSchedule {
vocabulary_id: string
due_date: string
priority: 'low' | 'medium' | 'high' | 'urgent'
review_type: 'new' | 'review' | 'difficult'
estimated_study_time: number // in minutes
}
export interface LearningAnalytics {
total_words_learned: number
total_study_time: number
average_accuracy: number
streak_days: number
words_due_for_review: number
mastery_distribution: {
beginner: number // 0-25
intermediate: number // 26-50
advanced: number // 51-75
mastered: number // 76-100
}
weekly_progress: {
week: string
words_studied: number
accuracy: number
study_time: number
}[]
error_patterns: {
pattern: string
count: number
improvement_suggestion: string
}[]
}

View File

@ -1,202 +0,0 @@
// Audio Manager for Text-to-Speech functionality
export class AudioManager {
constructor() {
this.speechSynthesis = window.speechSynthesis;
this.voices = [];
this.currentVoice = null;
this.isInitialized = false;
this.init();
}
async init() {
try {
// Wait for voices to be loaded
if (this.speechSynthesis.getVoices().length === 0) {
await new Promise(resolve => {
this.speechSynthesis.addEventListener('voiceschanged', resolve, { once: true });
});
}
this.voices = this.speechSynthesis.getVoices();
this.selectBestVoice();
this.isInitialized = true;
console.log('🔊 AudioManager initialized with', this.voices.length, 'voices');
} catch (error) {
console.error('AudioManager initialization failed:', error);
}
}
selectBestVoice() {
// Prefer English voices in order of preference
const preferredVoices = [
'Google US English',
'Microsoft Zira - English (United States)',
'Alex', // macOS
'Samantha', // macOS
'Google UK English Female',
'Microsoft Hazel - English (Great Britain)'
];
// Try to find preferred voice
for (const preferredName of preferredVoices) {
const voice = this.voices.find(v => v.name.includes(preferredName));
if (voice) {
this.currentVoice = voice;
console.log('🎤 Selected voice:', voice.name);
return;
}
}
// Fallback to first English voice
const englishVoice = this.voices.find(voice =>
voice.lang.startsWith('en')
);
if (englishVoice) {
this.currentVoice = englishVoice;
console.log('🎤 Using fallback voice:', englishVoice.name);
} else if (this.voices.length > 0) {
this.currentVoice = this.voices[0];
console.log('🎤 Using default voice:', this.voices[0].name);
}
}
async speak(text, options = {}) {
if (!this.isInitialized) {
await this.init();
}
return new Promise((resolve, reject) => {
if (!this.speechSynthesis) {
reject(new Error('Speech synthesis not supported'));
return;
}
// Stop any ongoing speech
this.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
// Configure utterance
utterance.voice = this.currentVoice;
utterance.rate = options.rate || 0.9;
utterance.pitch = options.pitch || 1;
utterance.volume = options.volume || 1;
// Event listeners
utterance.onstart = () => {
console.log('🔊 Started speaking:', text);
};
utterance.onend = () => {
console.log('✅ Finished speaking:', text);
resolve();
};
utterance.onerror = (event) => {
console.error('❌ Speech error:', event.error);
reject(new Error(`Speech synthesis error: ${event.error}`));
};
// Start speaking
this.speechSynthesis.speak(utterance);
});
}
stop() {
if (this.speechSynthesis) {
this.speechSynthesis.cancel();
}
}
// Specific method for vocabulary words
async speakWord(word, options = {}) {
const wordOptions = {
rate: 0.8, // Slower for vocabulary learning
pitch: 1,
volume: 1,
...options
};
try {
await this.speak(word, wordOptions);
} catch (error) {
console.error('Failed to speak word:', word, error);
// Fallback: show visual feedback
this.showSpeechFallback(word);
}
}
// Visual fallback when speech fails
showSpeechFallback(word) {
// Create temporary visual indicator
const indicator = document.createElement('div');
indicator.textContent = `🔊 "${word}"`;
indicator.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: var(--primary-teal);
color: white;
padding: 10px 15px;
border-radius: 5px;
font-size: 14px;
font-weight: 600;
z-index: 1000;
animation: fadeInOut 2s ease-in-out;
`;
// Add fade animation
const style = document.createElement('style');
style.textContent = `
@keyframes fadeInOut {
0% { opacity: 0; transform: translateY(-10px); }
20% { opacity: 1; transform: translateY(0); }
80% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(-10px); }
}
`;
document.head.appendChild(style);
document.body.appendChild(indicator);
// Remove after animation
setTimeout(() => {
if (indicator.parentNode) {
indicator.parentNode.removeChild(indicator);
}
if (style.parentNode) {
style.parentNode.removeChild(style);
}
}, 2000);
}
// Get available voices for user selection
getAvailableVoices() {
return this.voices.filter(voice => voice.lang.startsWith('en'));
}
// Set custom voice
setVoice(voiceName) {
const voice = this.voices.find(v => v.name === voiceName);
if (voice) {
this.currentVoice = voice;
console.log('🎤 Voice changed to:', voice.name);
}
}
// Check if audio is supported
isSupported() {
return !!(window.speechSynthesis && window.SpeechSynthesisUtterance);
}
// Get current voice info
getCurrentVoice() {
return this.currentVoice ? {
name: this.currentVoice.name,
lang: this.currentVoice.lang,
gender: this.currentVoice.name.toLowerCase().includes('female') ? 'female' :
this.currentVoice.name.toLowerCase().includes('male') ? 'male' : 'unknown'
} : null;
}
}

314
apps/web/src/utils/index.ts Normal file
View File

@ -0,0 +1,314 @@
// 工具函數集合
/**
* ID
*/
export const generateId = (prefix = 'id'): string => {
return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
}
/**
*
*/
export const debounce = <T extends (...args: any[]) => any>(
func: T,
wait: number
): ((...args: Parameters<T>) => void) => {
let timeout: NodeJS.Timeout | null = null
return (...args: Parameters<T>) => {
if (timeout) {
clearTimeout(timeout)
}
timeout = setTimeout(() => func.apply(null, args), wait)
}
}
/**
*
*/
export const throttle = <T extends (...args: any[]) => any>(
func: T,
wait: number
): ((...args: Parameters<T>) => void) => {
let inThrottle: boolean
return (...args: Parameters<T>) => {
if (!inThrottle) {
func.apply(null, args)
inThrottle = true
setTimeout(() => (inThrottle = false), wait)
}
}
}
/**
*
*/
export const deepMerge = <T = any>(target: T, ...sources: any[]): T => {
if (!sources.length) return target
const source = sources.shift()
if (isObject(target) && isObject(source)) {
for (const key in source) {
if (isObject(source[key])) {
if (!(target as any)[key]) Object.assign(target as any, { [key]: {} })
deepMerge((target as any)[key], source[key])
} else {
Object.assign(target as any, { [key]: source[key] })
}
}
}
return deepMerge(target, ...sources)
}
/**
*
*/
export const isObject = (item: any): boolean => {
return item && typeof item === 'object' && !Array.isArray(item)
}
/**
*
*/
export const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
/**
*
*/
export const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
/**
*
*/
export const formatNumber = (num: number): string => {
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
}
/**
*
*/
export const copyToClipboard = async (text: string): Promise<boolean> => {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
return true
} else {
// 降級方案
const textArea = document.createElement('textarea')
textArea.value = text
textArea.style.position = 'absolute'
textArea.style.left = '-999999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
try {
document.execCommand('copy')
return true
} catch (error) {
console.error('Copy failed:', error)
return false
} finally {
document.body.removeChild(textArea)
}
}
} catch (error) {
console.error('Copy to clipboard failed:', error)
return false
}
}
/**
*
*/
export const downloadFile = (url: string, filename?: string): void => {
const link = document.createElement('a')
link.href = url
if (filename) {
link.download = filename
}
link.style.display = 'none'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
/**
*
*/
export const sleep = (ms: number): Promise<void> => {
return new Promise(resolve => setTimeout(resolve, ms))
}
/**
* URL參數
*/
export const getUrlParams = (url?: string): Record<string, string> => {
const urlObject = new URL(url || window.location.href)
const params: Record<string, string> = {}
urlObject.searchParams.forEach((value, key) => {
params[key] = value
})
return params
}
/**
*
*/
export const isMobile = (): boolean => {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent
)
}
/**
* WebP
*/
export const supportsWebP = (): Promise<boolean> => {
return new Promise((resolve) => {
const webP = new Image()
webP.onload = webP.onerror = () => {
resolve(webP.height === 2)
}
webP.src = 'data:image/webp;base64,UklGRjoAAABXRUJQVlA4IC4AAACyAgCdASoCAAIALmk0mk0iIiIiIgBoSygABc6WWgAA/veff/0PP8bA//LwYAAA'
})
}
/**
* HTML標籤
*/
export const stripHtml = (html: string): string => {
const tmp = document.createElement('DIV')
tmp.innerHTML = html
return tmp.textContent || tmp.innerText || ''
}
/**
*
*/
export const truncateText = (text: string, maxLength: number, suffix = '...'): string => {
if (text.length <= maxLength) return text
return text.substring(0, maxLength - suffix.length) + suffix
}
/**
*
*/
export const randomColor = (): string => {
return `#${Math.floor(Math.random() * 16777215).toString(16)}`
}
/**
*
*/
export const isEmpty = (value: any): boolean => {
if (value === null || value === undefined) return true
if (typeof value === 'string' && value.trim() === '') return true
if (Array.isArray(value) && value.length === 0) return true
if (isObject(value) && Object.keys(value).length === 0) return true
return false
}
/**
*
*/
export const capitalize = (str: string): string => {
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()
}
/**
* kebab-case
*/
export const camelToKebab = (str: string): string => {
return str.replace(/([a-z0-9]|(?=[A-Z]))([A-Z])/g, '$1-$2').toLowerCase()
}
/**
* kebab-case轉駝峰
*/
export const kebabToCamel = (str: string): string => {
return str.replace(/-([a-z])/g, (match, letter) => letter.toUpperCase())
}
/**
*
*/
export const getFileExtension = (filename: string): string => {
return filename.slice((filename.lastIndexOf('.') - 1 >>> 0) + 2)
}
/**
*
*/
export const isValidEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}
/**
*
*/
export const isValidTaiwanPhone = (phone: string): boolean => {
const phoneRegex = /^09\d{8}$/
return phoneRegex.test(phone)
}
/**
*
*/
export const randomString = (length = 8): string => {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
let result = ''
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
return result
}
/**
*
*/
export const compareVersions = (version1: string, version2: string): number => {
const v1Parts = version1.split('.').map(Number)
const v2Parts = version2.split('.').map(Number)
const maxLength = Math.max(v1Parts.length, v2Parts.length)
for (let i = 0; i < maxLength; i++) {
const v1Part = v1Parts[i] || 0
const v2Part = v2Parts[i] || 0
if (v1Part > v2Part) return 1
if (v1Part < v2Part) return -1
}
return 0
}
// 常用的正則表達式
export const REGEX = {
email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
phone: /^09\d{8}$/,
url: /^https?:\/\/([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*)*\/?$/,
password: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d@$!%*?&]{8,}$/,
ipAddress: /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/,
hexColor: /^#?([a-f\d]{3}|[a-f\d]{6})$/i,
uuid: /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
}

View File

@ -0,0 +1,421 @@
interface ExportOptions {
includeCharts: boolean
includeStats: boolean
includeSuggestions: boolean
includeWeaknesses: boolean
}
interface AnalyticsData {
timeRange: string
overallStats: Array<{
title: string
value: string
subtitle?: string
change?: string
}>
chartData: {
masteryDistribution: any
progressTrend: any
performanceRadar: any
}
categoryStats: Array<{
category: string
total: number
mastered: number
progress: number
difficulty: number
}>
learningRecommendations: Array<{
title: string
description: string
priority: string
}>
identifiedWeaknesses: Array<{
category: string
severity: string
accuracy: number
avgResponseTime: number
}>
}
/**
*
* @param format ('pdf' | 'xlsx' | 'csv')
* @param data
* @param options
*/
export async function exportAnalyticsReport(
format: 'pdf' | 'xlsx' | 'csv',
data: AnalyticsData,
options: ExportOptions
): Promise<void> {
try {
switch (format) {
case 'pdf':
await exportToPDF(data, options)
break
case 'xlsx':
await exportToExcel(data, options)
break
case 'csv':
await exportToCSV(data, options)
break
default:
throw new Error(`不支援的匯出格式: ${format}`)
}
} catch (error) {
console.error('匯出報告失敗:', error)
throw error
}
}
/**
* PDF格式
*/
async function exportToPDF(data: AnalyticsData, options: ExportOptions): Promise<void> {
// 動態導入jsPDF以避免打包體積過大
const { jsPDF } = await import('jspdf')
const doc = new jsPDF()
let yPosition = 20
// 設定字體
doc.setFont('helvetica', 'bold')
doc.setFontSize(18)
// 標題
doc.text('詞彙學習分析報告', 20, yPosition)
yPosition += 15
// 時間範圍
doc.setFontSize(12)
doc.setFont('helvetica', 'normal')
doc.text(`報告期間: ${data.timeRange}`, 20, yPosition)
doc.text(`生成時間: ${new Date().toLocaleString('zh-TW')}`, 20, yPosition + 7)
yPosition += 25
// 整體統計
if (options.includeStats) {
doc.setFont('helvetica', 'bold')
doc.setFontSize(14)
doc.text('整體統計', 20, yPosition)
yPosition += 10
doc.setFont('helvetica', 'normal')
doc.setFontSize(10)
data.overallStats.forEach(stat => {
doc.text(`${stat.title}: ${stat.value}`, 25, yPosition)
if (stat.subtitle) {
doc.text(` ${stat.subtitle}`, 25, yPosition + 5)
yPosition += 5
}
if (stat.change) {
doc.text(` 變化: ${stat.change}`, 25, yPosition + 5)
yPosition += 5
}
yPosition += 8
})
yPosition += 10
}
// 詞彙分類統計表格
if (options.includeStats) {
doc.setFont('helvetica', 'bold')
doc.setFontSize(14)
doc.text('詞彙分類統計', 20, yPosition)
yPosition += 15
// 表格標題
const tableHeaders = ['分類', '總詞彙', '已掌握', '進度', '難度']
const colWidths = [40, 25, 25, 25, 25]
let xPosition = 20
doc.setFont('helvetica', 'bold')
doc.setFontSize(10)
tableHeaders.forEach((header, index) => {
doc.text(header, xPosition, yPosition)
xPosition += colWidths[index]
})
yPosition += 8
// 表格數據
doc.setFont('helvetica', 'normal')
data.categoryStats.forEach(row => {
xPosition = 20
const rowData = [
row.category,
row.total.toString(),
row.mastered.toString(),
`${row.progress}%`,
'★'.repeat(row.difficulty)
]
rowData.forEach((cell, index) => {
doc.text(cell, xPosition, yPosition)
xPosition += colWidths[index]
})
yPosition += 7
})
yPosition += 15
}
// 學習建議
if (options.includeSuggestions) {
// 檢查是否需要新頁面
if (yPosition > 250) {
doc.addPage()
yPosition = 20
}
doc.setFont('helvetica', 'bold')
doc.setFontSize(14)
doc.text('學習建議', 20, yPosition)
yPosition += 15
doc.setFont('helvetica', 'normal')
doc.setFontSize(10)
data.learningRecommendations.forEach((suggestion, index) => {
const priorityText = getPriorityText(suggestion.priority)
doc.text(`${index + 1}. ${suggestion.title} (${priorityText})`, 25, yPosition)
yPosition += 7
// 處理長文本換行
const lines = doc.splitTextToSize(suggestion.description, 150)
lines.forEach((line: string) => {
doc.text(line, 30, yPosition)
yPosition += 6
})
yPosition += 5
})
}
// 薄弱點分析
if (options.includeWeaknesses) {
if (yPosition > 250) {
doc.addPage()
yPosition = 20
}
doc.setFont('helvetica', 'bold')
doc.setFontSize(14)
doc.text('薄弱點分析', 20, yPosition)
yPosition += 15
doc.setFont('helvetica', 'normal')
doc.setFontSize(10)
data.identifiedWeaknesses.forEach(weakness => {
doc.text(`${weakness.category}`, 25, yPosition)
doc.text(`正確率: ${weakness.accuracy}%`, 35, yPosition + 6)
doc.text(`平均反應時間: ${weakness.avgResponseTime}ms`, 35, yPosition + 12)
yPosition += 20
})
}
// 頁腳
const pageCount = doc.getNumberOfPages()
for (let i = 1; i <= pageCount; i++) {
doc.setPage(i)
doc.setFont('helvetica', 'normal')
doc.setFontSize(8)
doc.text(`${i} 頁,共 ${pageCount}`, 170, 285)
doc.text('Drama Ling 學習分析報告', 20, 285)
}
// 下載檔案
const filename = `詞彙學習分析報告_${new Date().toISOString().split('T')[0]}.pdf`
doc.save(filename)
}
/**
* Excel格式
*/
async function exportToExcel(data: AnalyticsData, options: ExportOptions): Promise<void> {
// 動態導入xlsx以避免打包體積過大
const XLSX = await import('xlsx')
const workbook = XLSX.utils.book_new()
// 整體統計工作表
if (options.includeStats) {
const statsData = [
['項目', '數值', '說明', '變化'],
...data.overallStats.map(stat => [
stat.title,
stat.value,
stat.subtitle || '',
stat.change || ''
])
]
const statsWorksheet = XLSX.utils.aoa_to_sheet(statsData)
XLSX.utils.book_append_sheet(workbook, statsWorksheet, '整體統計')
}
// 詞彙分類統計工作表
if (options.includeStats) {
const categoryData = [
['詞彙分類', '總詞彙數', '已掌握', '進度(%)', '平均難度'],
...data.categoryStats.map(row => [
row.category,
row.total,
row.mastered,
row.progress,
row.difficulty
])
]
const categoryWorksheet = XLSX.utils.aoa_to_sheet(categoryData)
XLSX.utils.book_append_sheet(workbook, categoryWorksheet, '詞彙分類統計')
}
// 學習建議工作表
if (options.includeSuggestions) {
const suggestionsData = [
['建議標題', '詳細說明', '優先級'],
...data.learningRecommendations.map(suggestion => [
suggestion.title,
suggestion.description,
getPriorityText(suggestion.priority)
])
]
const suggestionsWorksheet = XLSX.utils.aoa_to_sheet(suggestionsData)
XLSX.utils.book_append_sheet(workbook, suggestionsWorksheet, '學習建議')
}
// 薄弱點分析工作表
if (options.includeWeaknesses) {
const weaknessesData = [
['薄弱領域', '嚴重程度', '正確率(%)', '平均反應時間(ms)'],
...data.identifiedWeaknesses.map(weakness => [
weakness.category,
getSeverityText(weakness.severity),
weakness.accuracy,
weakness.avgResponseTime
])
]
const weaknessesWorksheet = XLSX.utils.aoa_to_sheet(weaknessesData)
XLSX.utils.book_append_sheet(workbook, weaknessesWorksheet, '薄弱點分析')
}
// 下載檔案
const filename = `詞彙學習分析報告_${new Date().toISOString().split('T')[0]}.xlsx`
XLSX.writeFile(workbook, filename)
}
/**
* CSV格式
*/
async function exportToCSV(data: AnalyticsData, options: ExportOptions): Promise<void> {
let csvContent = ''
// CSV標題
csvContent += `詞彙學習分析報告\n`
csvContent += `報告期間,${data.timeRange}\n`
csvContent += `生成時間,${new Date().toLocaleString('zh-TW')}\n\n`
// 整體統計
if (options.includeStats) {
csvContent += '整體統計\n'
csvContent += '項目,數值,說明,變化\n'
data.overallStats.forEach(stat => {
csvContent += `${stat.title},${stat.value},${stat.subtitle || ''},${stat.change || ''}\n`
})
csvContent += '\n'
}
// 詞彙分類統計
if (options.includeStats) {
csvContent += '詞彙分類統計\n'
csvContent += '詞彙分類,總詞彙數,已掌握,進度(%),平均難度\n'
data.categoryStats.forEach(row => {
csvContent += `${row.category},${row.total},${row.mastered},${row.progress},${row.difficulty}\n`
})
csvContent += '\n'
}
// 學習建議
if (options.includeSuggestions) {
csvContent += '學習建議\n'
csvContent += '建議標題,詳細說明,優先級\n'
data.learningRecommendations.forEach(suggestion => {
csvContent += `${suggestion.title},"${suggestion.description}",${getPriorityText(suggestion.priority)}\n`
})
csvContent += '\n'
}
// 薄弱點分析
if (options.includeWeaknesses) {
csvContent += '薄弱點分析\n'
csvContent += '薄弱領域,嚴重程度,正確率(%),平均反應時間(ms)\n'
data.identifiedWeaknesses.forEach(weakness => {
csvContent += `${weakness.category},${getSeverityText(weakness.severity)},${weakness.accuracy},${weakness.avgResponseTime}\n`
})
}
// 下載檔案
const filename = `詞彙學習分析報告_${new Date().toISOString().split('T')[0]}.csv`
downloadTextFile(csvContent, filename, 'text/csv;charset=utf-8;')
}
/**
*
*/
function downloadTextFile(content: string, filename: string, mimeType: string): void {
const blob = new Blob(['\uFEFF' + content], { type: mimeType }) // 添加 BOM 以支援中文
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
/**
*
*/
function getPriorityText(priority: string): string {
const priorities: Record<string, string> = {
high: '高優先級',
medium: '中優先級',
low: '低優先級'
}
return priorities[priority] || '未知優先級'
}
/**
*
*/
function getSeverityText(severity: string): string {
const severities: Record<string, string> = {
high: '嚴重',
medium: '中等',
low: '輕微'
}
return severities[severity] || '未知'
}
/**
* (PDF匯出)
*/
export async function chartToImage(chartElement: HTMLCanvasElement): Promise<string> {
return new Promise((resolve) => {
chartElement.toBlob((blob) => {
if (blob) {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.readAsDataURL(blob)
}
})
})
}

View File

@ -0,0 +1,457 @@
/**
*
* Ebbinghaus SM-2
*/
export interface VocabularyReviewData {
id: string
lastReviewed: Date | null
reviewCount: number
easeFactor: number
interval: number // 間隔天數
nextReviewDate: Date
consecutiveCorrect: number
consecutiveWrong: number
averageResponseTime: number
difficultyLevel: number // 1-5
masteryLevel: number // 0-100
weaknessPatterns: WeaknessPattern[]
}
export interface WeaknessPattern {
type: 'spelling' | 'meaning' | 'pronunciation' | 'usage' | 'grammar'
severity: number // 0-1
lastOccurrence: Date
frequency: number
}
export interface ReviewSession {
vocabularyId: string
startTime: Date
endTime?: Date
responses: ReviewResponse[]
overallAccuracy: number
averageResponseTime: number
}
export interface ReviewResponse {
questionType: 'choice' | 'matching' | 'spelling' | 'pronunciation'
isCorrect: boolean
responseTime: number
confidence: number // 1-5 用戶自評信心程度
hintsUsed: number
timestamp: Date
}
/**
*
*
*/
export class SpacedRepetitionAlgorithm {
// SM-2 演算法的預設參數
private readonly MIN_EASE_FACTOR = 1.3
private readonly MAX_EASE_FACTOR = 2.5
private readonly DEFAULT_EASE_FACTOR = 2.5
private readonly EASE_FACTOR_ADJUSTMENT = 0.15
// 遺忘曲線參數
private readonly FORGETTING_CURVE_DECAY = 0.84
private readonly RETENTION_THRESHOLD = 0.9
/**
*
* @param reviewData
* @param sessionResult
* @returns
*/
calculateNextReview(reviewData: VocabularyReviewData, sessionResult: ReviewSession): VocabularyReviewData {
const updatedData = { ...reviewData }
const now = new Date()
// 更新基礎統計
updatedData.lastReviewed = now
updatedData.reviewCount += 1
updatedData.averageResponseTime = this.updateAverageResponseTime(
reviewData.averageResponseTime,
sessionResult.averageResponseTime,
reviewData.reviewCount
)
// 根據表現調整難度因子
const performanceScore = this.calculatePerformanceScore(sessionResult)
updatedData.easeFactor = this.adjustEaseFactor(reviewData.easeFactor, performanceScore)
// 更新連續正確/錯誤次數
if (sessionResult.overallAccuracy >= 0.8) {
updatedData.consecutiveCorrect += 1
updatedData.consecutiveWrong = 0
} else {
updatedData.consecutiveWrong += 1
updatedData.consecutiveCorrect = 0
}
// 計算新的間隔時間
updatedData.interval = this.calculateInterval(updatedData, performanceScore)
// 設置下次複習日期
updatedData.nextReviewDate = new Date(now.getTime() + updatedData.interval * 24 * 60 * 60 * 1000)
// 更新掌握程度
updatedData.masteryLevel = this.calculateMasteryLevel(updatedData, sessionResult)
// 分析薄弱點模式
updatedData.weaknessPatterns = this.analyzeWeaknessPatterns(reviewData.weaknessPatterns, sessionResult)
return updatedData
}
/**
* (0-1)
*/
private calculatePerformanceScore(session: ReviewSession): number {
const accuracyWeight = 0.6
const speedWeight = 0.2
const confidenceWeight = 0.2
// 正確率分數
const accuracyScore = session.overallAccuracy
// 速度分數 (反應時間越短分數越高)
const avgResponseTime = session.averageResponseTime
const speedScore = Math.max(0, 1 - (avgResponseTime - 1000) / 4000) // 1-5秒的範圍
// 信心程度分數
const avgConfidence = session.responses.reduce((sum, r) => sum + r.confidence, 0) / session.responses.length
const confidenceScore = (avgConfidence - 1) / 4 // 1-5 轉換為 0-1
return accuracyScore * accuracyWeight + speedScore * speedWeight + confidenceScore * confidenceWeight
}
/**
* 調
*/
private adjustEaseFactor(currentEaseFactor: number, performanceScore: number): number {
let newEaseFactor = currentEaseFactor
if (performanceScore >= 0.8) {
// 表現良好,增加難度因子
newEaseFactor += this.EASE_FACTOR_ADJUSTMENT
} else if (performanceScore < 0.6) {
// 表現不佳,降低難度因子
newEaseFactor -= this.EASE_FACTOR_ADJUSTMENT
}
// 限制在合理範圍內
return Math.max(this.MIN_EASE_FACTOR, Math.min(this.MAX_EASE_FACTOR, newEaseFactor))
}
/**
*
*/
private calculateInterval(reviewData: VocabularyReviewData, performanceScore: number): number {
let baseInterval: number
if (reviewData.reviewCount === 1) {
baseInterval = 1 // 第一次複習1天后
} else if (reviewData.reviewCount === 2) {
baseInterval = 6 // 第二次複習6天后
} else {
// 使用 SM-2 演算法
baseInterval = Math.round(reviewData.interval * reviewData.easeFactor)
}
// 根據表現調整間隔
let adjustmentFactor = 1.0
if (performanceScore >= 0.9) {
adjustmentFactor = 1.3 // 表現極好,延長間隔
} else if (performanceScore >= 0.8) {
adjustmentFactor = 1.1 // 表現良好,略微延長
} else if (performanceScore < 0.6) {
adjustmentFactor = 0.6 // 表現不佳,縮短間隔
} else if (performanceScore < 0.4) {
adjustmentFactor = 0.3 // 表現很差,大幅縮短間隔
}
// 考慮連續錯誤次數
if (reviewData.consecutiveWrong >= 2) {
adjustmentFactor *= 0.5 // 連續錯誤,進一步縮短間隔
}
// 考慮詞彙難度
const difficultyAdjustment = 1 + (reviewData.difficultyLevel - 3) * 0.1
const finalInterval = Math.max(1, Math.round(baseInterval * adjustmentFactor * difficultyAdjustment))
return Math.min(finalInterval, 365) // 最長不超過一年
}
/**
*
*/
private calculateMasteryLevel(reviewData: VocabularyReviewData, session: ReviewSession): number {
const currentLevel = reviewData.masteryLevel
const performanceScore = this.calculatePerformanceScore(session)
// 基於表現調整掌握程度
let adjustment = 0
if (performanceScore >= 0.9) {
adjustment = 15 // 表現極好
} else if (performanceScore >= 0.8) {
adjustment = 10 // 表現良好
} else if (performanceScore >= 0.6) {
adjustment = 5 // 表現一般
} else if (performanceScore >= 0.4) {
adjustment = -5 // 表現不佳
} else {
adjustment = -10 // 表現很差
}
// 考慮複習次數和間隔
const stabilityBonus = Math.min(5, reviewData.reviewCount)
const intervalBonus = Math.min(3, Math.log(reviewData.interval))
const newLevel = Math.max(0, Math.min(100, currentLevel + adjustment + stabilityBonus + intervalBonus))
return Math.round(newLevel)
}
/**
*
*/
private analyzeWeaknessPatterns(
currentPatterns: WeaknessPattern[],
session: ReviewSession
): WeaknessPattern[] {
const patterns = [...currentPatterns]
const now = new Date()
// 分析錯誤類型
const errorTypes = this.identifyErrorTypes(session.responses)
errorTypes.forEach(errorType => {
const existingPattern = patterns.find(p => p.type === errorType.type)
if (existingPattern) {
// 更新現有模式
existingPattern.severity = Math.min(1, existingPattern.severity + errorType.severity * 0.1)
existingPattern.lastOccurrence = now
existingPattern.frequency += 1
} else {
// 創建新模式
patterns.push({
type: errorType.type,
severity: errorType.severity,
lastOccurrence: now,
frequency: 1
})
}
})
// 減少長期未出現錯誤的嚴重程度
patterns.forEach(pattern => {
const daysSinceLastError = (now.getTime() - pattern.lastOccurrence.getTime()) / (24 * 60 * 60 * 1000)
if (daysSinceLastError > 7) {
pattern.severity = Math.max(0, pattern.severity - 0.05 * daysSinceLastError)
}
})
// 只保留嚴重程度 > 0.1 的模式
return patterns.filter(p => p.severity > 0.1)
}
/**
*
*/
private identifyErrorTypes(responses: ReviewResponse[]): Array<{type: WeaknessPattern['type'], severity: number}> {
const errorTypes: Array<{type: WeaknessPattern['type'], severity: number}> = []
responses.forEach(response => {
if (!response.isCorrect) {
// 根據問題類型和表現推斷錯誤類型
switch (response.questionType) {
case 'choice':
errorTypes.push({ type: 'meaning', severity: 0.6 })
break
case 'spelling':
errorTypes.push({ type: 'spelling', severity: 0.8 })
break
case 'pronunciation':
errorTypes.push({ type: 'pronunciation', severity: 0.7 })
break
case 'matching':
errorTypes.push({ type: 'usage', severity: 0.5 })
break
}
// 根據反應時間推斷
if (response.responseTime > 5000) {
errorTypes.push({ type: 'meaning', severity: 0.4 })
}
// 根據信心程度推斷
if (response.confidence <= 2) {
errorTypes.push({ type: 'meaning', severity: 0.3 })
}
}
})
return errorTypes
}
/**
*
*/
private updateAverageResponseTime(
currentAverage: number,
newResponseTime: number,
reviewCount: number
): number {
if (reviewCount === 1) {
return newResponseTime
}
// 使用指數移動平均
const alpha = 0.3 // 學習率
return currentAverage * (1 - alpha) + newResponseTime * alpha
}
/**
*
*/
static getTodaysReviewVocabulary(allVocabulary: VocabularyReviewData[]): VocabularyReviewData[] {
const today = new Date()
today.setHours(0, 0, 0, 0)
return allVocabulary
.filter(vocab => vocab.nextReviewDate <= today)
.sort((a, b) => {
// 優先級排序:過期時間越長,優先級越高
const overdueDaysA = Math.floor((today.getTime() - a.nextReviewDate.getTime()) / (24 * 60 * 60 * 1000))
const overdueDaysB = Math.floor((today.getTime() - b.nextReviewDate.getTime()) / (24 * 60 * 60 * 1000))
if (overdueDaysA !== overdueDaysB) {
return overdueDaysB - overdueDaysA // 過期時間長的排前面
}
// 其次考慮掌握程度低的
return a.masteryLevel - b.masteryLevel
})
}
/**
*
*/
static generateLearningPlan(
allVocabulary: VocabularyReviewData[],
daysAhead: number = 7
): Map<string, VocabularyReviewData[]> {
const plan = new Map<string, VocabularyReviewData[]>()
const today = new Date()
for (let i = 0; i < daysAhead; i++) {
const targetDate = new Date(today)
targetDate.setDate(today.getDate() + i)
targetDate.setHours(0, 0, 0, 0)
const nextDay = new Date(targetDate)
nextDay.setDate(targetDate.getDate() + 1)
const vocabularyForDay = allVocabulary.filter(vocab =>
vocab.nextReviewDate >= targetDate && vocab.nextReviewDate < nextDay
)
const dateKey = targetDate.toISOString().split('T')[0]
plan.set(dateKey, vocabularyForDay)
}
return plan
}
/**
*
*/
static analyzeLearningEfficiency(reviewHistory: ReviewSession[]): {
averageAccuracy: number
averageResponseTime: number
improvementTrend: number
strongestAreas: string[]
weakestAreas: string[]
} {
if (reviewHistory.length === 0) {
return {
averageAccuracy: 0,
averageResponseTime: 0,
improvementTrend: 0,
strongestAreas: [],
weakestAreas: []
}
}
const totalAccuracy = reviewHistory.reduce((sum, session) => sum + session.overallAccuracy, 0)
const averageAccuracy = totalAccuracy / reviewHistory.length
const totalResponseTime = reviewHistory.reduce((sum, session) => sum + session.averageResponseTime, 0)
const averageResponseTime = totalResponseTime / reviewHistory.length
// 計算改善趨勢
let improvementTrend = 0
if (reviewHistory.length >= 2) {
const recentSessions = reviewHistory.slice(-5) // 最近5次
const olderSessions = reviewHistory.slice(-10, -5) // 之前5次
if (olderSessions.length > 0 && recentSessions.length > 0) {
const recentAvg = recentSessions.reduce((sum, s) => sum + s.overallAccuracy, 0) / recentSessions.length
const olderAvg = olderSessions.reduce((sum, s) => sum + s.overallAccuracy, 0) / olderSessions.length
improvementTrend = (recentAvg - olderAvg) * 100 // 百分比改善
}
}
// 分析題型表現
const questionTypeStats = new Map<string, number[]>()
reviewHistory.forEach(session => {
session.responses.forEach(response => {
if (!questionTypeStats.has(response.questionType)) {
questionTypeStats.set(response.questionType, [])
}
questionTypeStats.get(response.questionType)!.push(response.isCorrect ? 1 : 0)
})
})
const typeAccuracies = Array.from(questionTypeStats.entries()).map(([type, results]) => ({
type,
accuracy: results.reduce((sum, r) => sum + r, 0) / results.length
}))
typeAccuracies.sort((a, b) => b.accuracy - a.accuracy)
return {
averageAccuracy,
averageResponseTime,
improvementTrend,
strongestAreas: typeAccuracies.slice(0, 2).map(t => t.type),
weakestAreas: typeAccuracies.slice(-2).map(t => t.type)
}
}
}
/**
*
*/
export function createDefaultVocabularyReviewData(vocabularyId: string): VocabularyReviewData {
return {
id: vocabularyId,
lastReviewed: null,
reviewCount: 0,
easeFactor: 2.5,
interval: 1,
nextReviewDate: new Date(), // 新詞彙立即需要學習
consecutiveCorrect: 0,
consecutiveWrong: 0,
averageResponseTime: 3000,
difficultyLevel: 3,
masteryLevel: 0,
weaknessPatterns: []
}
}

View File

@ -0,0 +1,581 @@
<template>
<div class="home-view">
<!-- Hero 區域 -->
<section class="hero">
<div class="hero-background">
<div class="hero-pattern"></div>
</div>
<div class="hero-container">
<div class="hero-content">
<div class="hero-logo">
<img src="/logo.svg" alt="Drama Ling" />
</div>
<h1 class="hero-title">Drama Ling</h1>
<h2 class="hero-subtitle">戲劇式語言學習平台</h2>
<p class="hero-description">
透過角色扮演和戲劇化對話讓語言學習變得生動有趣
從基礎對話到流利表達我們陪伴你的每一步成長
</p>
<div class="hero-actions">
<BaseButton
variant="primary"
size="lg"
@click="handleGetStarted"
>
開始學習
</BaseButton>
<BaseButton
variant="outline"
size="lg"
@click="handleLearnMore"
>
了解更多
</BaseButton>
<!-- 開發模式快速登入 -->
<BaseButton
v-if="isDevelopment"
variant="secondary"
size="lg"
icon="developer_mode"
@click="handleDevLogin"
class="dev-quick-login"
>
測試登入
</BaseButton>
</div>
<div class="hero-stats">
<div class="stat">
<div class="stat-number">10K+</div>
<div class="stat-label">學習者</div>
</div>
<div class="stat">
<div class="stat-number">500+</div>
<div class="stat-label">對話情境</div>
</div>
<div class="stat">
<div class="stat-number">98%</div>
<div class="stat-label">滿意度</div>
</div>
</div>
</div>
<div class="hero-visual">
<div class="demo-card">
<div class="demo-conversation">
<div class="demo-message user">
<div class="demo-avatar"></div>
<div class="demo-bubble">你好我想訂一張桌子</div>
</div>
<div class="demo-message bot">
<div class="demo-avatar bot-avatar"></div>
<div class="demo-bubble">歡迎請問幾位用餐</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- 特色功能 -->
<section class="features">
<div class="container">
<h2 class="section-title">為什麼選擇 Drama Ling</h2>
<div class="features-grid">
<BaseCard
v-for="feature in features"
:key="feature.id"
class="feature-card"
hoverable
>
<div class="feature-icon">
<QIcon :name="feature.icon" />
</div>
<h3>{{ feature.title }}</h3>
<p>{{ feature.description }}</p>
</BaseCard>
</div>
</div>
</section>
<!-- CTA 區域 -->
<section class="cta">
<div class="container">
<div class="cta-content">
<h2>準備好開始你的語言學習之旅了嗎</h2>
<p>加入數千名學習者的行列體驗不一樣的語言學習方式</p>
<div class="cta-actions">
<BaseButton
variant="primary"
size="lg"
@click="handleSignUp"
>
免費註冊
</BaseButton>
</div>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseCard from '@/components/base/BaseCard.vue'
const router = useRouter()
const authStore = useAuthStore()
//
const isDevelopment = import.meta.env.DEV
const features = [
{
id: 1,
icon: 'theater_comedy',
title: '戲劇化學習',
description: '透過角色扮演和情境對話,讓學習更加生動有趣,提升語言表達的自信心'
},
{
id: 2,
icon: 'mic',
title: '發音練習',
description: 'AI 語音識別系統即時糾正發音,讓你說出最標準的語音'
},
{
id: 3,
icon: 'psychology',
title: '智能適應',
description: '根據學習進度和能力調整難度,提供個人化的學習體驗'
},
{
id: 4,
icon: 'groups',
title: '社群學習',
description: '與全球學習者互動,分享學習心得,一起進步'
},
{
id: 5,
icon: 'timeline',
title: '進度追蹤',
description: '詳細的學習報告和成就系統,讓你清楚看見自己的進步'
},
{
id: 6,
icon: 'phone_android',
title: '隨時隨地',
description: '支援多平台使用,讓你在任何時間、任何地點都能學習'
}
]
const handleGetStarted = () => {
if (authStore.isAuthenticated) {
router.push('/learning')
} else {
router.push('/auth/register')
}
}
const handleLearnMore = () => {
//
const featuresSection = document.querySelector('.features')
if (featuresSection) {
featuresSection.scrollIntoView({ behavior: 'smooth' })
}
}
const handleSignUp = () => {
router.push('/auth/register')
}
//
const handleDevLogin = async () => {
if (!import.meta.env.DEV) return
try {
const result = await authStore.login({
email: 'test@dramaling.com',
password: 'test123',
rememberMe: true
})
if (result.success) {
router.push('/learning/vocabulary')
}
} catch (error) {
console.error('開發模式登入失敗:', error)
}
}
</script>
<style scoped>
.home-view {
min-height: 100vh;
}
.hero {
min-height: 100vh;
display: flex;
align-items: center;
position: relative;
overflow: hidden;
}
.hero-background {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg,
#00E5CC 0%,
#6C63FF 50%,
#9C27B0 100%);
}
.hero-background::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(44, 62, 80, 0.2);
}
.hero-pattern {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
radial-gradient(circle at 20% 30%, rgba(255,255,255,0.1) 0%, transparent 50%),
radial-gradient(circle at 80% 70%, rgba(255,255,255,0.1) 0%, transparent 50%);
background-size: 300px 300px;
animation: float 15s ease-in-out infinite;
}
.hero-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 48px;
align-items: center;
position: relative;
z-index: 1;
}
@media (max-width: 768px) {
.hero-container {
grid-template-columns: 1fr;
gap: 32px;
padding: 0 16px;
text-align: center;
}
}
.hero-content {
color: white;
}
.hero-logo {
margin-bottom: 16px;
}
.hero-logo img {
width: 80px;
height: 80px;
filter: brightness(0) invert(1);
}
.hero-title {
font-size: 3rem;
font-weight: 900;
margin: 0 0 8px 0;
line-height: 1.2;
}
@media (max-width: 480px) {
.hero-title {
font-size: 2.5rem;
}
}
.hero-subtitle {
font-size: 1.5rem;
font-weight: 300;
margin: 0 0 24px 0;
opacity: 0.9;
}
@media (max-width: 480px) {
.hero-subtitle {
font-size: 1.25rem;
}
}
.hero-description {
font-size: 1.125rem;
line-height: 1.6;
margin-bottom: 32px;
opacity: 0.8;
}
.hero-actions {
display: flex;
gap: 16px;
margin-bottom: 48px;
}
@media (max-width: 480px) {
.hero-actions {
flex-direction: column;
}
}
.hero-stats {
display: flex;
gap: 32px;
}
@media (max-width: 480px) {
.hero-stats {
justify-content: center;
}
}
.hero-visual {
display: flex;
justify-content: center;
align-items: center;
}
@media (max-width: 768px) {
.hero-visual {
order: -1;
}
}
.stat {
text-align: center;
}
.stat-number {
font-size: 1.875rem;
font-weight: 900;
color: white;
margin-bottom: 4px;
}
.stat-label {
font-size: 0.875rem;
opacity: 0.8;
}
.demo-card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-radius: 16px;
padding: 24px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
width: 350px;
}
.demo-conversation {
display: flex;
flex-direction: column;
gap: 16px;
}
.demo-message {
display: flex;
gap: 12px;
align-items: flex-start;
}
.demo-message.bot {
flex-direction: row-reverse;
}
.demo-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: #00E5CC;
flex-shrink: 0;
}
.demo-avatar.bot-avatar {
background: #6C63FF;
}
.demo-bubble {
background: #f8f9fa;
color: #2C3E50;
padding: 12px 16px;
border-radius: 8px;
font-size: 0.875rem;
max-width: 200px;
}
.demo-message.bot .demo-bubble {
background: #00E5CC;
color: #2C3E50;
}
.features {
padding: 80px 0;
background: #F7F9FC;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
}
@media (max-width: 768px) {
.container {
padding: 0 16px;
}
}
.section-title {
font-size: 2.5rem;
font-weight: 700;
text-align: center;
margin-bottom: 48px;
color: #2C3E50;
}
@media (max-width: 480px) {
.section-title {
font-size: 1.875rem;
margin-bottom: 32px;
}
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 32px;
}
@media (max-width: 480px) {
.features-grid {
grid-template-columns: 1fr;
gap: 24px;
}
}
.feature-card {
text-align: center;
padding: 32px;
}
.feature-icon {
margin-bottom: 16px;
}
.feature-icon .q-icon {
font-size: 48px;
color: #00E5CC;
}
.feature-card h3 {
font-size: 1.25rem;
font-weight: 700;
margin: 0 0 12px 0;
color: #2C3E50;
}
.feature-card p {
font-size: 1rem;
line-height: 1.6;
color: #7F8C8D;
margin: 0;
}
.cta {
padding: 80px 0;
background: linear-gradient(135deg,
rgba(0, 229, 204, 0.1) 0%,
rgba(108, 99, 255, 0.1) 100%);
}
.cta-content {
text-align: center;
max-width: 600px;
margin: 0 auto;
}
.cta-content h2 {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 16px;
color: #2C3E50;
}
@media (max-width: 480px) {
.cta-content h2 {
font-size: 1.875rem;
}
}
.cta-content p {
font-size: 1.125rem;
line-height: 1.6;
color: #7F8C8D;
margin-bottom: 32px;
}
.cta-actions {
display: flex;
justify-content: center;
}
/* 開發模式按鈕樣式 */
.dev-quick-login {
background: #ff9800 !important;
border-color: #ff9800 !important;
color: white !important;
box-shadow: 0 4px 12px rgba(255, 152, 0, 0.3) !important;
animation: devPulse 2s ease-in-out infinite;
}
.dev-quick-login:hover {
background: #f57c00 !important;
transform: translateY(-2px);
}
@keyframes devPulse {
0%, 100% {
box-shadow: 0 4px 12px rgba(255, 152, 0, 0.3);
}
50% {
box-shadow: 0 4px 20px rgba(255, 152, 0, 0.5);
}
}
@keyframes float {
0%, 100% {
transform: translateY(0px) rotate(0deg);
}
50% {
transform: translateY(-20px) rotate(2deg);
}
}
</style>

View File

@ -0,0 +1,70 @@
<template>
<div class="not-found-view">
<q-page class="flex flex-center">
<div class="text-center">
<q-icon name="sentiment_dissatisfied" size="120px" color="grey-5" />
<h1 class="text-h3 q-mt-lg q-mb-md">404</h1>
<h2 class="text-h5 q-mb-lg">頁面不存在</h2>
<p class="text-body1 text-grey-7 q-mb-xl">
抱歉您訪問的頁面不存在或已被移動
</p>
<div class="action-buttons">
<q-btn
color="primary"
size="lg"
label="回到首頁"
@click="goHome"
class="q-mr-md"
/>
<q-btn
color="secondary"
size="lg"
label="返回上一頁"
@click="goBack"
outline
/>
</div>
</div>
</q-page>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
const goHome = () => {
router.push({ name: 'home' })
}
const goBack = () => {
router.back()
}
</script>
<style scoped>
.not-found-view {
min-height: 100vh;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
}
.action-buttons {
display: flex;
justify-content: center;
gap: 16px;
flex-wrap: wrap;
}
@media (max-width: 600px) {
.action-buttons {
flex-direction: column;
align-items: center;
}
.action-buttons .q-btn {
width: 200px;
}
}
</style>

View File

@ -0,0 +1,544 @@
<template>
<div class="offline-view">
<div class="offline-container">
<div class="offline-icon">
<q-icon name="cloud_off" size="6rem" color="grey-6" />
</div>
<div class="offline-content">
<h1 class="offline-title">離線模式</h1>
<p class="offline-subtitle">你目前處於離線狀態但仍可使用部分功能</p>
<!-- 可用功能 -->
<div class="available-features">
<h3>離線可用功能</h3>
<q-card class="feature-card" flat>
<q-card-section>
<q-item>
<q-item-section avatar>
<q-icon name="quiz" color="primary" size="md" />
</q-item-section>
<q-item-section>
<q-item-label>已快取的詞彙練習</q-item-label>
<q-item-label caption>繼續之前下載的練習內容</q-item-label>
</q-item-section>
<q-item-section side>
<q-btn
flat
color="primary"
label="前往"
@click="goToPractice"
:disabled="!hasCachedVocabulary"
/>
</q-item-section>
</q-item>
</q-card-section>
</q-card>
<q-card class="feature-card" flat>
<q-card-section>
<q-item>
<q-item-section avatar>
<q-icon name="trending_up" color="green" size="md" />
</q-item-section>
<q-item-section>
<q-item-label>學習進度檢視</q-item-label>
<q-item-label caption>查看本地儲存的學習統計</q-item-label>
</q-item-section>
<q-item-section side>
<q-btn
flat
color="green"
label="前往"
@click="goToProgress"
/>
</q-item-section>
</q-item>
</q-card-section>
</q-card>
<q-card class="feature-card" flat>
<q-card-section>
<q-item>
<q-item-section avatar>
<q-icon name="volume_up" color="orange" size="md" />
</q-item-section>
<q-item-section>
<q-item-label>已下載的音頻</q-item-label>
<q-item-label caption>播放之前快取的發音檔案</q-item-label>
</q-item-section>
<q-item-section side>
<q-chip
:color="cachedAudioCount > 0 ? 'orange' : 'grey'"
text-color="white"
>
{{ cachedAudioCount }} 個檔案
</q-chip>
</q-item-section>
</q-item>
</q-card-section>
</q-card>
</div>
<!-- 限制功能 -->
<div class="limited-features">
<h3>需要網路連線</h3>
<q-list dense>
<q-item>
<q-item-section avatar>
<q-icon name="sync_disabled" color="red" size="sm" />
</q-item-section>
<q-item-section>
<q-item-label>進度同步</q-item-label>
<q-item-label caption>學習進度會在重新連線後同步</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section avatar>
<q-icon name="download_disabled" color="red" size="sm" />
</q-item-section>
<q-item-section>
<q-item-label>新內容下載</q-item-label>
<q-item-label caption>無法載入新的詞彙和練習</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section avatar>
<q-icon name="leaderboard_disabled" color="red" size="sm" />
</q-item-section>
<q-item-section>
<q-item-label>排行榜和社群功能</q-item-label>
<q-item-label caption>需要網路連線查看最新排名</q-item-label>
</q-item-section>
</q-item>
</q-list>
</div>
<!-- 連線狀態 -->
<div class="connection-status">
<q-card flat class="status-card">
<q-card-section class="text-center">
<div class="connection-indicator">
<q-icon
:name="isOnline ? 'wifi' : 'wifi_off'"
:color="isOnline ? 'green' : 'red'"
size="xl"
/>
</div>
<div class="status-text">
<strong>{{ isOnline ? '已連線' : '離線中' }}</strong>
</div>
<div class="status-description">
{{ isOnline ? '正在嘗試重新載入頁面...' : '正在檢查網路連線...' }}
</div>
</q-card-section>
</q-card>
</div>
<!-- 操作按鈕 -->
<div class="offline-actions">
<q-btn
color="primary"
icon="refresh"
label="重新載入"
@click="reloadPage"
class="q-mr-md"
/>
<q-btn
color="secondary"
icon="home"
label="回到首頁"
@click="goToHome"
outline
/>
</div>
</div>
</div>
<!-- 快取管理 (開發者模式) -->
<div v-if="isDev" class="cache-management">
<q-card flat>
<q-card-section>
<div class="text-h6">快取管理 (開發者)</div>
<div class="cache-info">
<p>詞彙快取: {{ cachedVocabularyCount }} </p>
<p>音頻快取: {{ cachedAudioCount }} 個檔案</p>
<p>圖片快取: {{ cachedImageCount }} 個檔案</p>
<p>API 快取: {{ cachedApiCount }} 個請求</p>
</div>
<div class="cache-actions">
<q-btn
flat
color="orange"
icon="cleaning_services"
label="清除快取"
@click="clearCache"
class="q-mr-sm"
/>
<q-btn
flat
color="blue"
icon="info"
label="快取詳情"
@click="showCacheDetails = !showCacheDetails"
/>
</div>
</q-card-section>
</q-card>
<q-expansion-item
v-if="showCacheDetails"
icon="storage"
label="快取詳細資訊"
class="q-mt-md"
>
<q-card>
<q-card-section>
<pre class="cache-details">{{ cacheDetails }}</pre>
</q-card-section>
</q-card>
</q-expansion-item>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useVocabularyStore } from '@/stores/vocabulary'
import { useQuasar } from 'quasar'
const router = useRouter()
const vocabularyStore = useVocabularyStore()
const $q = useQuasar()
//
const isOnline = ref(navigator.onLine)
const isDev = ref(import.meta.env.DEV)
const showCacheDetails = ref(false)
const cacheDetails = ref('正在載入快取資訊...')
//
const cachedVocabularyCount = ref(0)
const cachedAudioCount = ref(0)
const cachedImageCount = ref(0)
const cachedApiCount = ref(0)
//
const hasCachedVocabulary = computed(() => {
return vocabularyStore.vocabularies.length > 0 || cachedVocabularyCount.value > 0
})
//
const updateOnlineStatus = () => {
isOnline.value = navigator.onLine
if (isOnline.value) {
//
setTimeout(() => {
reloadPage()
}, 1000)
}
}
const reloadPage = () => {
window.location.reload()
}
const goToHome = () => {
router.push('/')
}
const goToPractice = () => {
if (hasCachedVocabulary.value) {
router.push('/learning/vocabulary/practice')
}
}
const goToProgress = () => {
router.push('/profile/progress')
}
const clearCache = async () => {
try {
if ('caches' in window) {
const cacheNames = await caches.keys()
await Promise.all(
cacheNames.map(cacheName => caches.delete(cacheName))
)
}
// localStorage
const keysToKeep = ['auth-token', 'user-preferences']
const allKeys = Object.keys(localStorage)
allKeys.forEach(key => {
if (!keysToKeep.includes(key)) {
localStorage.removeItem(key)
}
})
$q.notify({
type: 'positive',
message: '快取已清除',
icon: 'cleaning_services'
})
//
await loadCacheStats()
} catch (error) {
console.error('清除快取失敗:', error)
$q.notify({
type: 'negative',
message: '清除快取失敗',
icon: 'error'
})
}
}
const loadCacheStats = async () => {
try {
if ('caches' in window) {
const cacheNames = await caches.keys()
let totalVocabulary = 0
let totalAudio = 0
let totalImages = 0
let totalApi = 0
const details = {
caches: {},
localStorage: {},
indexedDB: 'N/A'
} as any
for (const cacheName of cacheNames) {
const cache = await caches.open(cacheName)
const requests = await cache.keys()
details.caches[cacheName] = requests.length
if (cacheName.includes('vocabulary')) {
totalVocabulary += requests.length
} else if (cacheName.includes('audio')) {
totalAudio += requests.length
} else if (cacheName.includes('image')) {
totalImages += requests.length
} else if (cacheName.includes('api')) {
totalApi += requests.length
}
}
// localStorage
details.localStorage = {
keys: Object.keys(localStorage),
totalSize: JSON.stringify(localStorage).length
}
cachedVocabularyCount.value = totalVocabulary
cachedAudioCount.value = totalAudio
cachedImageCount.value = totalImages
cachedApiCount.value = totalApi
cacheDetails.value = JSON.stringify(details, null, 2)
}
} catch (error) {
console.error('載入快取統計失敗:', error)
cacheDetails.value = '載入快取資訊失敗: ' + error.message
}
}
//
onMounted(async () => {
//
window.addEventListener('online', updateOnlineStatus)
window.addEventListener('offline', updateOnlineStatus)
//
await loadCacheStats()
//
const checkConnection = setInterval(() => {
updateOnlineStatus()
}, 5000)
//
onUnmounted(() => {
window.removeEventListener('online', updateOnlineStatus)
window.removeEventListener('offline', updateOnlineStatus)
clearInterval(checkConnection)
})
})
</script>
<style lang="scss" scoped>
.offline-view {
min-height: 100vh;
display: flex;
flex-direction: column;
background: linear-gradient(135deg, $background-primary 0%, $background-secondary 100%);
color: $text-primary;
padding: $space-4;
}
.offline-container {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
max-width: 800px;
margin: 0 auto;
text-align: center;
}
.offline-icon {
margin-bottom: $space-6;
opacity: 0.7;
}
.offline-content {
width: 100%;
}
.offline-title {
font-size: $text-4xl;
font-weight: 800;
color: $text-primary;
margin-bottom: $space-2;
}
.offline-subtitle {
font-size: $text-xl;
color: $text-secondary;
margin-bottom: $space-8;
}
.available-features {
margin-bottom: $space-8;
text-align: left;
h3 {
font-size: $text-lg;
color: $primary-teal;
margin-bottom: $space-4;
text-align: center;
}
.feature-card {
background: rgba($card-background, 0.8);
margin-bottom: $space-3;
border-radius: $radius-lg;
backdrop-filter: blur(10px);
}
}
.limited-features {
margin-bottom: $space-8;
text-align: left;
h3 {
font-size: $text-lg;
color: $error-red;
margin-bottom: $space-4;
text-align: center;
}
.q-list {
background: rgba($card-background, 0.5);
border-radius: $radius-lg;
padding: $space-4;
}
}
.connection-status {
margin-bottom: $space-6;
.status-card {
background: rgba($card-background, 0.8);
border-radius: $radius-lg;
backdrop-filter: blur(10px);
}
.connection-indicator {
margin-bottom: $space-3;
}
.status-text {
font-size: $text-lg;
margin-bottom: $space-2;
}
.status-description {
font-size: $text-base;
color: $text-secondary;
}
}
.offline-actions {
display: flex;
justify-content: center;
gap: $space-4;
margin-bottom: $space-8;
}
.cache-management {
margin-top: $space-8;
max-width: 800px;
margin-left: auto;
margin-right: auto;
.cache-info {
margin: $space-4 0;
padding: $space-4;
background: rgba($background-dark, 0.5);
border-radius: $radius-md;
p {
margin: $space-1 0;
font-family: 'JetBrains Mono', monospace;
font-size: $text-sm;
}
}
.cache-actions {
display: flex;
gap: $space-2;
}
.cache-details {
background: $background-dark;
padding: $space-4;
border-radius: $radius-md;
font-size: $text-xs;
line-height: 1.4;
overflow-x: auto;
color: $text-secondary;
}
}
@media (max-width: 768px) {
.offline-view {
padding: $space-3;
}
.offline-title {
font-size: $text-3xl;
}
.offline-subtitle {
font-size: $text-lg;
}
.offline-actions {
flex-direction: column;
align-items: center;
.q-btn {
width: 100%;
max-width: 300px;
}
}
}
</style>

View File

@ -0,0 +1,327 @@
<template>
<div class="forgot-password-view">
<BaseCard class="forgot-password-card">
<template #header>
<div class="back-button">
<QBtn
flat
round
icon="arrow_back"
size="sm"
@click="router.back()"
aria-label="返回上一頁"
/>
</div>
<h2 class="forgot-password-title">忘記密碼</h2>
<p class="forgot-password-subtitle">
輸入你的電子郵件地址我們將發送重設密碼的連結給你
</p>
</template>
<div v-if="!emailSent" class="forgot-password-form">
<form @submit.prevent="handleSendResetEmail">
<BaseInput
v-model="email"
type="email"
label="電子郵件"
placeholder="請輸入你的電子郵件地址"
prefix-icon="email"
:error="emailError"
:disabled="isLoading"
required
/>
<BaseButton
type="submit"
variant="primary"
size="lg"
block
:loading="isLoading"
:disabled="!canSubmit"
>
發送重設連結
</BaseButton>
</form>
</div>
<div v-else class="success-message">
<div class="success-icon">
<QIcon name="mark_email_read" />
</div>
<h3>郵件已發送</h3>
<p>
我們已將重設密碼的連結發送到 <strong>{{ email }}</strong>
</p>
<p class="instruction">
請檢查你的收件匣也可能在垃圾郵件資料夾中點擊連結來重設密碼
</p>
<div class="resend-section">
<p class="resend-text">沒有收到郵件</p>
<BaseButton
variant="outline"
size="md"
:disabled="resendCooldown > 0 || isLoading"
@click="handleResendEmail"
>
<span v-if="resendCooldown > 0">
重新發送 ({{ resendCooldown }}s)
</span>
<span v-else>重新發送</span>
</BaseButton>
</div>
</div>
<template #footer>
<div class="login-prompt">
想起密碼了
<router-link to="/auth/login" class="login-link">
返回登入
</router-link>
</div>
</template>
</BaseCard>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useUIStore } from '@/stores/ui'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import { isValidEmail } from '@/utils'
const router = useRouter()
const authStore = useAuthStore()
const uiStore = useUIStore()
//
const email = ref('')
const emailError = ref('')
const isLoading = ref(false)
const emailSent = ref(false)
const resendCooldown = ref(0)
//
const canSubmit = computed(() => {
return email.value && isValidEmail(email.value) && !isLoading.value
})
//
const validateEmail = () => {
emailError.value = ''
if (!email.value) {
emailError.value = '請輸入電子郵件地址'
return false
}
if (!isValidEmail(email.value)) {
emailError.value = '請輸入有效的電子郵件地址'
return false
}
return true
}
const handleSendResetEmail = async () => {
if (!validateEmail()) return
isLoading.value = true
try {
const result = await authStore.forgotPassword(email.value)
if (result.success) {
emailSent.value = true
uiStore.showSuccessToast('郵件已發送', '請檢查你的收件匣')
startResendCooldown()
} else {
uiStore.showErrorToast('發送失敗', result.error)
if (result.error?.includes('email')) {
emailError.value = result.error
}
}
} catch (error) {
uiStore.showErrorToast('發送失敗', '發生未知錯誤,請稍後再試')
} finally {
isLoading.value = false
}
}
const handleResendEmail = async () => {
if (resendCooldown.value > 0) return
isLoading.value = true
try {
const result = await authStore.forgotPassword(email.value)
if (result.success) {
uiStore.showSuccessToast('郵件已重新發送', '請檢查你的收件匣')
startResendCooldown()
} else {
uiStore.showErrorToast('重新發送失敗', result.error)
}
} catch (error) {
uiStore.showErrorToast('重新發送失敗', '發生未知錯誤,請稍後再試')
} finally {
isLoading.value = false
}
}
const startResendCooldown = () => {
resendCooldown.value = 60 // 60
const countdown = setInterval(() => {
resendCooldown.value -= 1
if (resendCooldown.value <= 0) {
clearInterval(countdown)
}
}, 1000)
}
</script>
<style lang="scss" scoped>
.forgot-password-view {
width: 100%;
max-width: 400px;
margin: 0 auto;
}
.forgot-password-card {
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
border: 1px solid rgba($divider, 0.2);
backdrop-filter: blur(10px);
position: relative;
}
.back-button {
position: absolute;
top: -$space-2;
left: -$space-2;
.q-btn {
color: $text-secondary;
&:hover {
color: $primary-teal;
background: rgba($primary-teal, 0.1);
}
}
}
.forgot-password-title {
font-size: $text-2xl;
font-weight: 700;
color: $text-primary;
margin: 0 0 $space-3 0;
text-align: center;
}
.forgot-password-subtitle {
font-size: $text-sm;
color: $text-secondary;
margin: 0;
text-align: center;
line-height: 1.5;
}
.forgot-password-form {
display: flex;
flex-direction: column;
gap: $space-6;
}
.success-message {
text-align: center;
.success-icon {
margin-bottom: $space-4;
.q-icon {
font-size: 64px;
color: $success-green;
}
}
h3 {
font-size: $text-xl;
font-weight: 700;
color: $text-primary;
margin: 0 0 $space-4 0;
}
p {
font-size: $text-base;
color: $text-secondary;
margin: 0 0 $space-3 0;
line-height: 1.5;
strong {
color: $text-primary;
word-break: break-word;
}
}
.instruction {
font-size: $text-sm;
color: $text-tertiary;
padding: $space-4;
background: rgba($primary-teal, 0.05);
border-radius: $radius-md;
border-left: 4px solid $primary-teal;
}
.resend-section {
margin-top: $space-6;
padding-top: $space-4;
border-top: 1px solid rgba($divider, 0.3);
.resend-text {
font-size: $text-sm;
margin-bottom: $space-3;
}
}
}
.login-prompt {
text-align: center;
font-size: $text-sm;
color: $text-secondary;
.login-link {
color: $primary-teal;
text-decoration: none;
font-weight: 600;
margin-left: $space-1;
transition: color 0.3s ease;
&:hover {
color: $primary-teal-light;
text-decoration: underline;
}
}
}
//
@include respond-to(xs) {
.forgot-password-view {
padding: $space-4;
}
.back-button {
top: $space-2;
left: $space-2;
}
.forgot-password-title {
margin-top: $space-8;
}
}
</style>

View File

@ -0,0 +1,354 @@
<template>
<div class="login-view">
<BaseCard class="login-card">
<template #header>
<h2 class="login-title">歡迎回來</h2>
<p class="login-subtitle">登入你的 Drama Ling 帳戶</p>
<!-- 開發模式提示 -->
<div v-if="isDevelopment" class="dev-notice">
<q-banner class="bg-amber-1 text-amber-9 q-mb-md" rounded>
<template v-slot:avatar>
<q-icon name="developer_mode" color="amber" />
</template>
<div class="text-body2">
<strong>🚧 開發模式</strong><br>
使用測試帳戶登入<br>
📧 <code>test@dramaling.com</code><br>
🔑 <code>test123</code>
</div>
<template v-slot:action>
<q-btn
flat
color="amber"
label="快速填入"
size="sm"
@click="fillTestCredentials"
/>
</template>
</q-banner>
</div>
</template>
<form @submit.prevent="handleLogin" class="login-form">
<BaseInput
v-model="form.email"
type="email"
label="電子郵件"
placeholder="請輸入電子郵件地址"
prefix-icon="email"
:error="errors.email"
:disabled="isLoading"
required
/>
<BaseInput
v-model="form.password"
:type="showPassword ? 'text' : 'password'"
label="密碼"
placeholder="請輸入密碼"
prefix-icon="lock"
:suffix-icon="showPassword ? 'visibility_off' : 'visibility'"
:error="errors.password"
:disabled="isLoading"
required
@suffix-click="togglePassword"
/>
<div class="login-options">
<QCheckbox
v-model="form.rememberMe"
label="記住我"
:disabled="isLoading"
/>
<router-link
to="/auth/forgot-password"
class="forgot-password-link"
>
忘記密碼
</router-link>
</div>
<BaseButton
type="submit"
variant="primary"
size="lg"
block
:loading="isLoading"
:disabled="!canSubmit"
>
登入
</BaseButton>
<div class="divider">
<span></span>
</div>
<BaseButton
variant="outline"
size="lg"
block
icon="login"
:disabled="isLoading"
@click="handleGoogleLogin"
>
使用 Google 登入
</BaseButton>
</form>
<template #footer>
<div class="register-prompt">
還沒有帳戶
<router-link to="/auth/register" class="register-link">
立即註冊
</router-link>
</div>
</template>
</BaseCard>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useUIStore } from '@/stores/ui'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import { isValidEmail } from '@/utils'
const router = useRouter()
const authStore = useAuthStore()
const uiStore = useUIStore()
//
const form = reactive({
email: '',
password: '',
rememberMe: false
})
const errors = reactive({
email: '',
password: ''
})
const isLoading = ref(false)
const showPassword = ref(false)
//
const isDevelopment = import.meta.env.DEV
//
const canSubmit = computed(() => {
return form.email &&
form.password &&
isValidEmail(form.email) &&
!isLoading.value
})
//
const validateForm = () => {
errors.email = ''
errors.password = ''
if (!form.email) {
errors.email = '請輸入電子郵件地址'
return false
}
if (!isValidEmail(form.email)) {
errors.email = '請輸入有效的電子郵件地址'
return false
}
if (!form.password) {
errors.password = '請輸入密碼'
return false
}
if (form.password.length < 6) {
errors.password = '密碼長度至少 6 個字元'
return false
}
return true
}
const handleLogin = async () => {
if (!validateForm()) return
isLoading.value = true
authStore.clearError()
try {
const result = await authStore.login({
email: form.email,
password: form.password,
rememberMe: form.rememberMe
})
if (result.success) {
uiStore.showSuccessToast('登入成功', '歡迎回來!')
//
const redirectPath = authStore.redirectPath || '/learning'
router.push(redirectPath)
} else {
uiStore.showErrorToast('登入失敗', result.error)
//
if (result.error?.includes('email')) {
errors.email = result.error
} else if (result.error?.includes('password')) {
errors.password = result.error
}
}
} catch (error) {
uiStore.showErrorToast('登入失敗', '發生未知錯誤,請稍後再試')
} finally {
isLoading.value = false
}
}
const handleGoogleLogin = async () => {
uiStore.showInfoToast('功能開發中', '第三方登入功能即將推出')
// TODO: Google
}
const togglePassword = () => {
showPassword.value = !showPassword.value
}
// ()
const fillTestCredentials = () => {
if (import.meta.env.DEV) {
form.email = 'test@dramaling.com'
form.password = 'test123'
form.rememberMe = true
clearErrors()
}
}
//
const clearErrors = () => {
authStore.clearError()
errors.email = ''
errors.password = ''
}
</script>
<style lang="scss" scoped>
.login-view {
width: 100%;
max-width: 400px;
margin: 0 auto;
}
.login-card {
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
border: 1px solid rgba($divider, 0.2);
backdrop-filter: blur(10px);
}
.login-title {
font-size: $text-2xl;
font-weight: 700;
color: $text-primary;
margin: 0 0 $space-2 0;
text-align: center;
}
.login-subtitle {
font-size: $text-sm;
color: $text-secondary;
margin: 0;
text-align: center;
}
.login-form {
display: flex;
flex-direction: column;
gap: $space-6;
}
.login-options {
display: flex;
justify-content: space-between;
align-items: center;
margin: $space-2 0;
.q-checkbox {
font-size: $text-sm;
}
}
.forgot-password-link {
font-size: $text-sm;
color: $primary-teal;
text-decoration: none;
transition: color 0.3s ease;
&:hover {
color: $primary-teal-light;
text-decoration: underline;
}
}
.divider {
position: relative;
text-align: center;
margin: $space-4 0;
&::before {
content: '';
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 1px;
background: $divider;
}
span {
background: $card-background;
padding: 0 $space-4;
font-size: $text-sm;
color: $text-secondary;
}
}
.register-prompt {
text-align: center;
font-size: $text-sm;
color: $text-secondary;
.register-link {
color: $primary-teal;
text-decoration: none;
font-weight: 600;
margin-left: $space-1;
transition: color 0.3s ease;
&:hover {
color: $primary-teal-light;
text-decoration: underline;
}
}
}
//
@include respond-to(xs) {
.login-view {
padding: $space-4;
}
.login-options {
flex-direction: column;
gap: $space-3;
align-items: flex-start;
}
}
</style>

View File

@ -0,0 +1,462 @@
<template>
<div class="register-view">
<BaseCard class="register-card">
<template #header>
<h2 class="register-title">加入 Drama Ling</h2>
<p class="register-subtitle">開始你的語言學習之旅</p>
</template>
<form @submit.prevent="handleRegister" class="register-form">
<BaseInput
v-model="form.username"
type="text"
label="用戶名稱"
placeholder="請輸入用戶名稱"
prefix-icon="person"
:error="errors.username"
:disabled="isLoading"
required
/>
<BaseInput
v-model="form.email"
type="email"
label="電子郵件"
placeholder="請輸入電子郵件地址"
prefix-icon="email"
:error="errors.email"
:disabled="isLoading"
required
/>
<BaseInput
v-model="form.password"
:type="showPassword ? 'text' : 'password'"
label="密碼"
placeholder="請輸入密碼(至少 8 個字元)"
prefix-icon="lock"
:suffix-icon="showPassword ? 'visibility_off' : 'visibility'"
:error="errors.password"
:disabled="isLoading"
required
@suffix-click="togglePassword"
/>
<BaseInput
v-model="form.confirmPassword"
:type="showConfirmPassword ? 'text' : 'password'"
label="確認密碼"
placeholder="請再次輸入密碼"
prefix-icon="lock"
:suffix-icon="showConfirmPassword ? 'visibility_off' : 'visibility'"
:error="errors.confirmPassword"
:disabled="isLoading"
required
@suffix-click="toggleConfirmPassword"
/>
<div class="password-strength">
<div class="strength-bar">
<div
class="strength-fill"
:class="`strength-${passwordStrength.level}`"
:style="{ width: `${passwordStrength.score}%` }"
></div>
</div>
<span class="strength-text">{{ passwordStrength.text }}</span>
</div>
<div class="terms-checkbox">
<QCheckbox
v-model="form.agreeToTerms"
:disabled="isLoading"
>
<template #default>
我同意
<a href="/terms" target="_blank" class="terms-link">使用條款</a>
<a href="/privacy" target="_blank" class="terms-link">隱私政策</a>
</template>
</QCheckbox>
<div v-if="errors.terms" class="error-text">{{ errors.terms }}</div>
</div>
<BaseButton
type="submit"
variant="primary"
size="lg"
block
:loading="isLoading"
:disabled="!canSubmit"
>
註冊帳戶
</BaseButton>
<div class="divider">
<span></span>
</div>
<BaseButton
variant="outline"
size="lg"
block
icon="login"
:disabled="isLoading"
@click="handleGoogleRegister"
>
使用 Google 註冊
</BaseButton>
</form>
<template #footer>
<div class="login-prompt">
已經有帳戶了
<router-link to="/auth/login" class="login-link">
立即登入
</router-link>
</div>
</template>
</BaseCard>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useUIStore } from '@/stores/ui'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import { isValidEmail } from '@/utils'
const router = useRouter()
const authStore = useAuthStore()
const uiStore = useUIStore()
//
const form = reactive({
username: '',
email: '',
password: '',
confirmPassword: '',
agreeToTerms: false
})
const errors = reactive({
username: '',
email: '',
password: '',
confirmPassword: '',
terms: ''
})
const isLoading = ref(false)
const showPassword = ref(false)
const showConfirmPassword = ref(false)
//
const passwordStrength = computed(() => {
const password = form.password
let score = 0
let level = 'weak'
let text = '密碼強度:弱'
if (password.length >= 8) score += 20
if (password.length >= 12) score += 10
if (/[a-z]/.test(password)) score += 20
if (/[A-Z]/.test(password)) score += 20
if (/[0-9]/.test(password)) score += 20
if (/[^A-Za-z0-9]/.test(password)) score += 10
if (score >= 80) {
level = 'strong'
text = '密碼強度:強'
} else if (score >= 60) {
level = 'medium'
text = '密碼強度:中'
}
return { score, level, text }
})
//
const canSubmit = computed(() => {
return form.username &&
form.email &&
form.password &&
form.confirmPassword &&
form.agreeToTerms &&
isValidEmail(form.email) &&
form.password === form.confirmPassword &&
form.password.length >= 8 &&
!isLoading.value
})
//
watch(() => form.password, () => {
if (form.confirmPassword && form.password !== form.confirmPassword) {
errors.confirmPassword = '密碼不匹配'
} else {
errors.confirmPassword = ''
}
})
watch(() => form.confirmPassword, () => {
if (form.confirmPassword && form.password !== form.confirmPassword) {
errors.confirmPassword = '密碼不匹配'
} else {
errors.confirmPassword = ''
}
})
//
const validateForm = () => {
errors.username = ''
errors.email = ''
errors.password = ''
errors.confirmPassword = ''
errors.terms = ''
let isValid = true
if (!form.username) {
errors.username = '請輸入用戶名稱'
isValid = false
} else if (form.username.length < 3) {
errors.username = '用戶名稱至少 3 個字元'
isValid = false
} else if (!/^[a-zA-Z0-9_\u4e00-\u9fa5]+$/.test(form.username)) {
errors.username = '用戶名稱只能包含字母、數字、底線和中文'
isValid = false
}
if (!form.email) {
errors.email = '請輸入電子郵件地址'
isValid = false
} else if (!isValidEmail(form.email)) {
errors.email = '請輸入有效的電子郵件地址'
isValid = false
}
if (!form.password) {
errors.password = '請輸入密碼'
isValid = false
} else if (form.password.length < 8) {
errors.password = '密碼長度至少 8 個字元'
isValid = false
}
if (!form.confirmPassword) {
errors.confirmPassword = '請確認密碼'
isValid = false
} else if (form.password !== form.confirmPassword) {
errors.confirmPassword = '密碼不匹配'
isValid = false
}
if (!form.agreeToTerms) {
errors.terms = '請同意使用條款和隱私政策'
isValid = false
}
return isValid
}
const handleRegister = async () => {
if (!validateForm()) return
isLoading.value = true
authStore.clearError()
try {
const result = await authStore.register({
username: form.username,
email: form.email,
password: form.password,
confirmPassword: form.confirmPassword,
agreeToTerms: form.agreeToTerms
})
if (result.success) {
uiStore.showSuccessToast('註冊成功', '歡迎加入 Drama Ling')
//
router.push('/learning')
} else {
uiStore.showErrorToast('註冊失敗', result.error)
//
if (result.error?.includes('username')) {
errors.username = result.error
} else if (result.error?.includes('email')) {
errors.email = result.error
} else if (result.error?.includes('password')) {
errors.password = result.error
}
}
} catch (error) {
uiStore.showErrorToast('註冊失敗', '發生未知錯誤,請稍後再試')
} finally {
isLoading.value = false
}
}
const handleGoogleRegister = async () => {
uiStore.showInfoToast('功能開發中', '第三方註冊功能即將推出')
// TODO: Google
}
const togglePassword = () => {
showPassword.value = !showPassword.value
}
const toggleConfirmPassword = () => {
showConfirmPassword.value = !showConfirmPassword.value
}
</script>
<style lang="scss" scoped>
.register-view {
width: 100%;
max-width: 450px;
margin: 0 auto;
}
.register-card {
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
border: 1px solid rgba($divider, 0.2);
backdrop-filter: blur(10px);
}
.register-title {
font-size: $text-2xl;
font-weight: 700;
color: $text-primary;
margin: 0 0 $space-2 0;
text-align: center;
}
.register-subtitle {
font-size: $text-sm;
color: $text-secondary;
margin: 0;
text-align: center;
}
.register-form {
display: flex;
flex-direction: column;
gap: $space-5;
}
.password-strength {
margin: -$space-2 0 $space-2 0;
.strength-bar {
height: 4px;
background: rgba($divider, 0.3);
border-radius: 2px;
overflow: hidden;
margin-bottom: $space-2;
.strength-fill {
height: 100%;
transition: all 0.3s ease;
border-radius: 2px;
&.strength-weak {
background: $error-red;
}
&.strength-medium {
background: $warning-orange;
}
&.strength-strong {
background: $success-green;
}
}
}
.strength-text {
font-size: $text-xs;
color: $text-secondary;
}
}
.terms-checkbox {
.q-checkbox {
font-size: $text-sm;
line-height: 1.5;
:deep(.q-checkbox__label) {
line-height: 1.5;
}
}
.terms-link {
color: $primary-teal;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.error-text {
font-size: $text-xs;
color: $error-red;
margin-top: $space-1;
}
}
.divider {
position: relative;
text-align: center;
margin: $space-4 0;
&::before {
content: '';
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 1px;
background: $divider;
}
span {
background: $card-background;
padding: 0 $space-4;
font-size: $text-sm;
color: $text-secondary;
}
}
.login-prompt {
text-align: center;
font-size: $text-sm;
color: $text-secondary;
.login-link {
color: $primary-teal;
text-decoration: none;
font-weight: 600;
margin-left: $space-1;
transition: color 0.3s ease;
&:hover {
color: $primary-teal-light;
text-decoration: underline;
}
}
}
//
@include respond-to(xs) {
.register-view {
padding: $space-4;
}
}
</style>

View File

@ -0,0 +1,262 @@
<template>
<div class="register-view">
<BaseCard class="register-card">
<h2 class="register-title">加入 Drama Ling</h2>
<p class="register-subtitle">開始你的語言學習之旅</p>
<form @submit.prevent="handleRegister" class="register-form">
<BaseInput
v-model="form.username"
type="text"
label="用戶名稱"
placeholder="請輸入用戶名稱"
:error="errors.username"
:disabled="isLoading"
required
/>
<BaseInput
v-model="form.email"
type="email"
label="電子郵件"
placeholder="請輸入電子郵件地址"
:error="errors.email"
:disabled="isLoading"
required
/>
<BaseInput
v-model="form.password"
type="password"
label="密碼"
placeholder="請輸入密碼(至少 8 個字元)"
:error="errors.password"
:disabled="isLoading"
required
/>
<BaseInput
v-model="form.confirmPassword"
type="password"
label="確認密碼"
placeholder="請再次輸入密碼"
:error="errors.confirmPassword"
:disabled="isLoading"
required
/>
<div class="terms-checkbox">
<label class="checkbox-wrapper">
<input
type="checkbox"
v-model="form.agreeToTerms"
:disabled="isLoading"
/>
<span class="checkbox-text">
我同意
<a href="/terms" target="_blank" class="terms-link">使用條款</a>
<a href="/privacy" target="_blank" class="terms-link">隱私政策</a>
</span>
</label>
<div v-if="errors.terms" class="error-text">{{ errors.terms }}</div>
</div>
<BaseButton
type="submit"
variant="primary"
size="lg"
:disabled="!canSubmit"
>
註冊帳戶
</BaseButton>
<div class="divider">
<span></span>
</div>
<BaseButton
variant="outline"
size="lg"
:disabled="isLoading"
@click="handleGoogleRegister"
>
使用 Google 註冊
</BaseButton>
</form>
<div class="login-prompt">
已經有帳戶了
<router-link to="/auth/login" class="login-link">
立即登入
</router-link>
</div>
</BaseCard>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { useRouter } from 'vue-router'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import { isValidEmail } from '@/utils'
const router = useRouter()
//
const form = reactive({
username: '',
email: '',
password: '',
confirmPassword: '',
agreeToTerms: false
})
const errors = reactive({
username: '',
email: '',
password: '',
confirmPassword: '',
terms: ''
})
const isLoading = ref(false)
//
const canSubmit = computed(() => {
return form.username &&
form.email &&
form.password &&
form.confirmPassword &&
form.agreeToTerms &&
isValidEmail(form.email) &&
form.password === form.confirmPassword &&
form.password.length >= 8 &&
!isLoading.value
})
const handleRegister = async () => {
console.log('註冊表單提交:', form)
alert('註冊功能開發中')
}
const handleGoogleRegister = async () => {
alert('Google 註冊功能開發中')
}
</script>
<style scoped>
.register-view {
width: 100%;
max-width: 450px;
margin: 0 auto;
}
.register-card {
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(74, 85, 104, 0.2);
backdrop-filter: blur(10px);
}
.register-title {
font-size: 1.5rem;
font-weight: 700;
color: #FFFFFF;
margin: 0 0 0.5rem 0;
text-align: center;
}
.register-subtitle {
font-size: 0.875rem;
color: #B8BCC8;
margin: 0 0 2rem 0;
text-align: center;
}
.register-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.terms-checkbox {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.checkbox-wrapper {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 0.875rem;
line-height: 1.5;
}
.checkbox-text {
color: #B8BCC8;
}
.terms-link {
color: #00E5CC;
text-decoration: none;
}
.terms-link:hover {
text-decoration: underline;
}
.error-text {
font-size: 0.75rem;
color: #EF4444;
margin-top: 0.25rem;
}
.divider {
position: relative;
text-align: center;
margin: 1rem 0;
}
.divider::before {
content: '';
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 1px;
background: #4A5568;
}
.divider span {
background: #3A4A5C;
padding: 0 1rem;
font-size: 0.875rem;
color: #B8BCC8;
}
.login-prompt {
text-align: center;
font-size: 0.875rem;
color: #B8BCC8;
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid #4A5568;
}
.login-link {
color: #00E5CC;
text-decoration: none;
font-weight: 600;
margin-left: 0.25rem;
transition: color 0.3s ease;
}
.login-link:hover {
color: #33E8D1;
text-decoration: underline;
}
</style>

View File

@ -0,0 +1,94 @@
<template>
<div class="dialogue-view">
<q-page class="q-pa-md">
<div class="text-center q-mb-lg">
<h1 class="text-h4 q-mb-sm">對話練習</h1>
<p class="text-body1 text-grey-7">互動式對話情境練習</p>
</div>
<div class="row justify-center">
<div class="col-12 col-md-10">
<q-card class="dialogue-card">
<q-card-section>
<div class="text-h6">對話場景: {{ dialogueId }}</div>
<p class="text-body2 text-grey-7">在餐廳點餐的情境對話</p>
</q-card-section>
<q-card-section class="dialogue-content">
<div class="dialogue-message user-message">
<q-avatar color="primary" text-color="white" icon="person" />
<div class="message-bubble">
您好我想要點餐
</div>
</div>
<div class="dialogue-message ai-message">
<q-avatar color="secondary" text-color="white" icon="smart_toy" />
<div class="message-bubble">
歡迎光臨請問您想要什麼
</div>
</div>
</q-card-section>
<q-card-actions class="justify-center">
<q-btn color="primary" label="繼續對話" />
<q-btn flat label="重新開始" />
</q-card-actions>
</q-card>
</div>
</div>
</q-page>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const dialogueId = computed(() => route.params.id)
</script>
<style scoped>
.dialogue-view {
min-height: 100vh;
}
.dialogue-card {
max-width: 800px;
margin: 0 auto;
}
.dialogue-content {
max-height: 400px;
overflow-y: auto;
}
.dialogue-message {
display: flex;
align-items: flex-start;
margin-bottom: 16px;
}
.dialogue-message.user-message {
justify-content: flex-end;
}
.dialogue-message.ai-message {
justify-content: flex-start;
}
.message-bubble {
max-width: 70%;
padding: 12px 16px;
border-radius: 18px;
margin: 0 8px;
}
.user-message .message-bubble {
background-color: #e3f2fd;
border-bottom-right-radius: 4px;
}
.ai-message .message-bubble {
background-color: #f5f5f5;
border-bottom-left-radius: 4px;
}
</style>

View File

@ -0,0 +1,45 @@
<template>
<div class="learning-home">
<q-page class="flex flex-center">
<div class="text-center">
<h1 class="text-h3 q-mb-md">學習地圖</h1>
<p class="text-h6 text-grey-7">歡迎來到 Drama Ling 學習中心</p>
<div class="q-mt-xl">
<q-btn
color="primary"
size="lg"
label="開始學習"
@click="startLearning"
class="q-mr-md"
/>
<q-btn
color="secondary"
size="lg"
label="查看進度"
@click="viewProgress"
/>
</div>
</div>
</q-page>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
const startLearning = () => {
router.push({ name: 'vocabulary' })
}
const viewProgress = () => {
router.push({ name: 'progress' })
}
</script>
<style scoped>
.learning-home {
min-height: 100vh;
}
</style>

View File

@ -0,0 +1,175 @@
<template>
<div class="pronunciation-view">
<q-page class="q-pa-md">
<div class="text-center q-mb-lg">
<h1 class="text-h4 q-mb-sm">發音練習</h1>
<p class="text-body1 text-grey-7">AI 輔助發音矯正練習</p>
</div>
<div class="row justify-center">
<div class="col-12 col-md-8">
<q-card class="pronunciation-card">
<q-card-section>
<div class="text-h6">練習詞彙: {{ pronunciationId }}</div>
<div class="pronunciation-target">
<div class="target-word">"Restaurant"</div>
<div class="phonetic">/ˈrɛstərɑnt/</div>
</div>
</q-card-section>
<q-card-section class="recording-section">
<div class="recording-area">
<q-btn
round
size="xl"
:color="isRecording ? 'negative' : 'primary'"
:icon="isRecording ? 'stop' : 'mic'"
@click="toggleRecording"
class="recording-btn"
/>
<div class="recording-status">
{{ isRecording ? '錄音中...' : '點擊開始錄音' }}
</div>
<div v-if="hasRecording" class="playback-controls q-mt-md">
<q-btn
icon="play_arrow"
label="播放我的發音"
@click="playRecording"
class="q-mr-sm"
/>
<q-btn
icon="volume_up"
label="播放標準發音"
@click="playTarget"
/>
</div>
</div>
</q-card-section>
<q-card-section v-if="feedback">
<div class="feedback-section">
<div class="text-h6 q-mb-sm">發音評估</div>
<q-linear-progress
:value="feedback.accuracy"
color="positive"
class="q-mb-sm"
/>
<div class="text-body2">準確度: {{ Math.round(feedback.accuracy * 100) }}%</div>
<div class="feedback-tips q-mt-sm">
<strong>建議:</strong> {{ feedback.tip }}
</div>
</div>
</q-card-section>
<q-card-actions class="justify-center">
<q-btn color="primary" label="下一個詞彙" />
<q-btn flat label="重新練習" />
</q-card-actions>
</q-card>
</div>
</div>
</q-page>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const pronunciationId = computed(() => route.params.id)
const isRecording = ref(false)
const hasRecording = ref(false)
const feedback = ref(null as any)
const toggleRecording = () => {
isRecording.value = !isRecording.value
if (!isRecording.value) {
hasRecording.value = true
// AI
setTimeout(() => {
feedback.value = {
accuracy: 0.85,
tip: '注意 "au" 的發音,可以更圓潤一些'
}
}, 1000)
}
}
const playRecording = () => {
//
}
const playTarget = () => {
//
}
</script>
<style scoped>
.pronunciation-view {
min-height: 100vh;
}
.pronunciation-card {
max-width: 700px;
margin: 0 auto;
}
.pronunciation-target {
text-align: center;
padding: 24px;
background: #f8f9fa;
border-radius: 12px;
margin: 16px 0;
}
.target-word {
font-size: 2.5em;
font-weight: bold;
color: #1976d2;
margin-bottom: 8px;
}
.phonetic {
font-size: 1.2em;
color: #666;
font-family: 'Courier New', monospace;
}
.recording-section {
text-align: center;
padding: 32px;
}
.recording-area {
display: flex;
flex-direction: column;
align-items: center;
}
.recording-btn {
margin-bottom: 16px;
box-shadow: 0 8px 16px rgba(0,0,0,0.1);
}
.recording-status {
font-size: 1.1em;
color: #666;
}
.feedback-section {
background: #e8f5e8;
padding: 20px;
border-radius: 8px;
border-left: 4px solid #4caf50;
}
.feedback-tips {
background: #fff;
padding: 12px;
border-radius: 6px;
margin-top: 12px;
}
</style>

View File

@ -0,0 +1,101 @@
<template>
<div class="roleplay-view">
<q-page class="q-pa-md">
<div class="text-center q-mb-lg">
<h1 class="text-h4 q-mb-sm">角色扮演</h1>
<p class="text-body1 text-grey-7">沉浸式角色扮演學習</p>
</div>
<div class="row justify-center">
<div class="col-12 col-md-10">
<q-card class="roleplay-card">
<q-card-section>
<div class="text-h6">場景: {{ roleplayId }}</div>
<p class="text-body2 text-grey-7">在咖啡廳與朋友聊天</p>
<q-chip color="primary" text-color="white" icon="person">
您的角色: 顧客
</q-chip>
</q-card-section>
<q-card-section class="roleplay-stage">
<div class="stage-background">
<q-icon name="local_cafe" size="120px" color="grey-4" />
<div class="role-indicator">
<q-avatar size="80px" color="primary">
<q-icon name="person" size="40px" />
</q-avatar>
<div class="role-name"></div>
</div>
</div>
</q-card-section>
<q-card-section>
<div class="scenario-prompt">
<strong>情境提示:</strong> 您想要點一杯拿鐵咖啡和一塊蛋糕請與服務員對話
</div>
</q-card-section>
<q-card-actions class="justify-center">
<q-btn color="primary" size="lg" label="開始角色扮演" />
<q-btn flat label="查看提示" />
</q-card-actions>
</q-card>
</div>
</div>
</q-page>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const roleplayId = computed(() => route.params.id)
</script>
<style scoped>
.roleplay-view {
min-height: 100vh;
}
.roleplay-card {
max-width: 900px;
margin: 0 auto;
}
.roleplay-stage {
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
min-height: 300px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.stage-background {
position: relative;
text-align: center;
}
.role-indicator {
position: absolute;
bottom: -40px;
left: 50%;
transform: translateX(-50%);
text-align: center;
}
.role-name {
margin-top: 8px;
font-weight: bold;
color: #1976d2;
}
.scenario-prompt {
background-color: #fff3e0;
padding: 16px;
border-radius: 8px;
border-left: 4px solid #ff9800;
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,837 @@
<!-- 詞彙選擇題結果頁面 (Page_Vocab_Choice_Results_W) -->
<!-- 依據 function-specs 結果分析規格Web端特色詳細統計圖表匯出功能 -->
<template>
<AppLayout>
<div class="vocabulary-choice-results">
<!-- 結果標題 -->
<div class="results-header">
<h1 class="results-title">練習結果</h1>
<div class="session-info">
<span class="session-date">{{ formatDate(sessionData?.startTime) }}</span>
<span class="session-type">選擇題練習</span>
</div>
</div>
<!-- 主要結果區域 -->
<div class="results-main" v-if="practiceResult">
<!-- 總體得分卡片 -->
<div class="score-card">
<div class="score-circle">
<div class="score-value">{{ Math.round(practiceResult.overallScore) }}</div>
<div class="score-label">總分</div>
</div>
<div class="mastery-level">
<div class="level-badge" :class="`level-${practiceResult.masteryLevel}`">
{{ getMasteryLevelName(practiceResult.masteryLevel) }}
</div>
<div class="level-description">
{{ getMasteryDescription(practiceResult.masteryLevel) }}
</div>
</div>
</div>
<!-- 能力分析雷達圖 -->
<div class="ability-analysis">
<h2 class="section-title">能力分析</h2>
<div class="radar-chart">
<!-- 簡化版雷達圖使用CSS實現 -->
<div class="radar-container">
<div class="radar-axis">
<div class="axis-label axis-recognition">識別能力</div>
<div class="axis-label axis-comprehension">理解能力</div>
<div class="axis-label axis-application">應用能力</div>
<div class="axis-label axis-speed">反應速度</div>
</div>
<div class="radar-data">
<div
class="data-point recognition"
:style="{
'--score': practiceResult.recognitionScore,
transform: `translate(-50%, -50%) scale(${practiceResult.recognitionScore / 100})`
}"
></div>
<div
class="data-point comprehension"
:style="{
'--score': practiceResult.comprehensionScore,
transform: `translate(-50%, -50%) scale(${practiceResult.comprehensionScore / 100})`
}"
></div>
<div
class="data-point application"
:style="{
'--score': practiceResult.applicationScore,
transform: `translate(-50%, -50%) scale(${practiceResult.applicationScore / 100})`
}"
></div>
<div
class="data-point speed"
:style="{
'--score': practiceResult.responseSpeedScore,
transform: `translate(-50%, -50%) scale(${practiceResult.responseSpeedScore / 100})`
}"
></div>
</div>
</div>
</div>
<div class="ability-scores">
<div class="ability-item">
<span class="ability-name">識別能力</span>
<div class="ability-bar">
<div
class="ability-fill recognition"
:style="{ width: `${practiceResult.recognitionScore}%` }"
></div>
</div>
<span class="ability-score">{{ Math.round(practiceResult.recognitionScore) }}</span>
</div>
<div class="ability-item">
<span class="ability-name">理解能力</span>
<div class="ability-bar">
<div
class="ability-fill comprehension"
:style="{ width: `${practiceResult.comprehensionScore}%` }"
></div>
</div>
<span class="ability-score">{{ Math.round(practiceResult.comprehensionScore) }}</span>
</div>
<div class="ability-item">
<span class="ability-name">應用能力</span>
<div class="ability-bar">
<div
class="ability-fill application"
:style="{ width: `${practiceResult.applicationScore}%` }"
></div>
</div>
<span class="ability-score">{{ Math.round(practiceResult.applicationScore) }}</span>
</div>
<div class="ability-item">
<span class="ability-name">反應速度</span>
<div class="ability-bar">
<div
class="ability-fill speed"
:style="{ width: `${practiceResult.responseSpeedScore}%` }"
></div>
</div>
<span class="ability-score">{{ Math.round(practiceResult.responseSpeedScore) }}</span>
</div>
</div>
</div>
<!-- 詳細統計 -->
<div class="detailed-stats">
<h2 class="section-title">詳細統計</h2>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon">🎯</div>
<div class="stat-value">{{ Math.round(practiceResult.accuracy) }}%</div>
<div class="stat-label">正確率</div>
</div>
<div class="stat-card">
<div class="stat-icon"></div>
<div class="stat-value">{{ formatTime(practiceResult.averageResponseTime) }}</div>
<div class="stat-label">平均反應時間</div>
</div>
<div class="stat-card">
<div class="stat-icon">📊</div>
<div class="stat-value">{{ sessionData?.correctAnswers }}/{{ sessionData?.totalQuestions }}</div>
<div class="stat-label">答對題數</div>
</div>
<div class="stat-card">
<div class="stat-icon"></div>
<div class="stat-value">+{{ practiceResult.experienceGained }}</div>
<div class="stat-label">經驗值</div>
</div>
</div>
</div>
<!-- 分析與建議 -->
<div class="analysis-section">
<div class="weakness-analysis">
<h3 class="analysis-title">薄弱點分析</h3>
<p class="analysis-content">{{ practiceResult.weaknessAnalysis }}</p>
</div>
<div class="improvement-suggestions">
<h3 class="analysis-title">改進建議</h3>
<ul class="suggestions-list">
<li
v-for="suggestion in practiceResult.improvementSuggestions"
:key="suggestion"
class="suggestion-item"
>
{{ suggestion }}
</li>
</ul>
</div>
</div>
<!-- 獎勵展示 -->
<div class="rewards-section" v-if="practiceResult.rewards && practiceResult.rewards.length > 0">
<h2 class="section-title">獲得獎勵</h2>
<div class="rewards-grid">
<div
v-for="reward in practiceResult.rewards"
:key="`${reward.type}-${reward.amount}`"
class="reward-item"
:class="`reward-${reward.type}`"
>
<div class="reward-icon">{{ getRewardIcon(reward.type) }}</div>
<div class="reward-amount">+{{ reward.amount }}</div>
<div class="reward-description">{{ reward.description }}</div>
</div>
</div>
</div>
</div>
<!-- 操作按鈕 -->
<div class="results-actions">
<button
class="action-btn secondary"
@click="retryPractice"
>
<Icon name="arrow-left" />
<span>重新練習</span>
</button>
<button
class="action-btn secondary"
@click="exportResults"
>
<Icon name="download" />
<span>匯出結果</span>
</button>
<button
class="action-btn primary"
@click="continueNext"
>
<Icon name="arrow-right" />
<span>繼續學習</span>
</button>
</div>
</div>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { usePracticeStore } from '@/stores/practice'
import type { PracticeResult, PracticeSession } from '@/types/practice'
import AppLayout from '@/layouts/AppLayout.vue'
import Icon from '@/components/ui/Icon.vue'
// Props
const props = defineProps<{
sessionId: string
}>()
// Composables
const router = useRouter()
const practiceStore = usePracticeStore()
//
const practiceResult = ref<PracticeResult | null>(null)
const sessionData = ref<PracticeSession | null>(null)
//
const currentSession = computed(() => practiceStore.currentSession)
//
function getMasteryLevelName(level: string): string {
const levelNames: Record<string, string> = {
'initial': '初學',
'familiar': '熟悉',
'application': '應用',
'mastered': '掌握'
}
return levelNames[level] || level
}
function getMasteryDescription(level: string): string {
const descriptions: Record<string, string> = {
'initial': '繼續加油,多多練習!',
'familiar': '不錯的開始,持續進步中',
'application': '表現良好,已能靈活運用',
'mastered': '優秀!已完全掌握'
}
return descriptions[level] || ''
}
function getRewardIcon(type: string): string {
const icons: Record<string, string> = {
'experience': '⚡',
'diamond': '💎',
'achievement': '🏆',
'life': '❤️'
}
return icons[type] || '🎁'
}
function formatDate(date?: Date): string {
if (!date) return ''
return new Intl.DateTimeFormat('zh-TW', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(new Date(date))
}
function formatTime(milliseconds: number): string {
const seconds = milliseconds / 1000
return `${seconds.toFixed(1)}`
}
async function retryPractice(): Promise<void> {
//
router.push({ name: 'vocabulary-choice-practice' })
}
async function exportResults(): Promise<void> {
if (!practiceResult.value || !sessionData.value) return
//
const exportData = {
sessionId: props.sessionId,
date: formatDate(sessionData.value.startTime),
type: '選擇題練習',
overallScore: practiceResult.value.overallScore,
masteryLevel: getMasteryLevelName(practiceResult.value.masteryLevel),
accuracy: practiceResult.value.accuracy,
averageResponseTime: formatTime(practiceResult.value.averageResponseTime),
correctAnswers: sessionData.value.correctAnswers,
totalQuestions: sessionData.value.totalQuestions,
recognitionScore: practiceResult.value.recognitionScore,
comprehensionScore: practiceResult.value.comprehensionScore,
applicationScore: practiceResult.value.applicationScore,
responseSpeedScore: practiceResult.value.responseSpeedScore,
weaknessAnalysis: practiceResult.value.weaknessAnalysis,
improvementSuggestions: practiceResult.value.improvementSuggestions
}
// JSON
const dataStr = JSON.stringify(exportData, null, 2)
const dataBlob = new Blob([dataStr], { type: 'application/json' })
const url = URL.createObjectURL(dataBlob)
const link = document.createElement('a')
link.href = url
link.download = `vocabulary-practice-result-${props.sessionId}.json`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
async function continueNext(): Promise<void> {
//
router.push({ name: 'learning' })
}
//
onMounted(() => {
// Store
sessionData.value = currentSession.value
if (sessionData.value) {
// StoreAPI
practiceResult.value = {
sessionId: props.sessionId,
overallScore: sessionData.value.score,
masteryLevel: sessionData.value.score >= 90 ? 'mastered' :
sessionData.value.score >= 75 ? 'application' :
sessionData.value.score >= 60 ? 'familiar' : 'initial',
recognitionScore: Math.min(100, sessionData.value.score + 5),
comprehensionScore: Math.max(0, sessionData.value.score - 5),
applicationScore: Math.max(0, sessionData.value.score - 10),
responseSpeedScore: Math.max(0, 100 - (sessionData.value.averageResponseTime / 1000 - 2) * 10),
averageResponseTime: sessionData.value.averageResponseTime,
accuracy: (sessionData.value.correctAnswers / sessionData.value.totalQuestions) * 100,
weaknessAnalysis: sessionData.value.correctAnswers === sessionData.value.totalQuestions
? '表現優秀,沒有明顯弱點!'
: `需要加強練習,建議重複學習錯誤的詞彙`,
improvementSuggestions: [
'多使用新詞彙造句練習',
'複習今天學習的內容',
'嘗試在日常對話中使用新詞彙'
],
nextPracticeTopics: [],
experienceGained: Math.floor(sessionData.value.score / 10),
rewards: sessionData.value.score >= 90 ? [{
type: 'diamond' as const,
amount: 10,
description: '完美表現獎勵鑽石'
}] : []
}
}
})
</script>
<style lang="scss" scoped>
.vocabulary-choice-results {
min-height: 100vh;
padding: var(--space-6);
background: var(--bg-primary);
.results-header {
text-align: center;
margin-bottom: var(--space-8);
.results-title {
font-size: var(--text-3xl);
font-weight: 700;
color: var(--text-primary);
margin: 0 0 var(--space-2) 0;
}
.session-info {
display: flex;
justify-content: center;
gap: var(--space-4);
color: var(--text-secondary);
font-size: var(--text-sm);
.session-type {
padding: var(--space-1) var(--space-2);
background: var(--primary-light);
color: var(--primary);
border-radius: var(--radius-full);
font-weight: 500;
}
}
}
.results-main {
max-width: 900px;
margin: 0 auto;
display: grid;
gap: var(--space-8);
.score-card {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-8);
padding: var(--space-8);
background: var(--bg-secondary);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg);
.score-circle {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 120px;
height: 120px;
border-radius: 50%;
background: conic-gradient(
var(--primary) 0deg,
var(--primary) calc(var(--score, 0) * 3.6deg),
var(--border-light) calc(var(--score, 0) * 3.6deg),
var(--border-light) 360deg
);
position: relative;
&::before {
content: '';
position: absolute;
width: 100px;
height: 100px;
border-radius: 50%;
background: var(--bg-secondary);
}
.score-value {
font-size: var(--text-3xl);
font-weight: 700;
color: var(--text-primary);
z-index: 1;
}
.score-label {
font-size: var(--text-sm);
color: var(--text-secondary);
z-index: 1;
}
}
.mastery-level {
text-align: center;
.level-badge {
display: inline-block;
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-full);
font-weight: 600;
font-size: var(--text-lg);
margin-bottom: var(--space-2);
&.level-initial {
background: var(--error-light);
color: var(--error);
}
&.level-familiar {
background: var(--warning-light);
color: var(--warning);
}
&.level-application {
background: var(--info-light);
color: var(--info);
}
&.level-mastered {
background: var(--success-light);
color: var(--success);
}
}
.level-description {
color: var(--text-secondary);
font-size: var(--text-sm);
}
}
}
.ability-analysis {
.section-title {
font-size: var(--text-xl);
font-weight: 600;
color: var(--text-primary);
margin: 0 0 var(--space-6) 0;
text-align: center;
}
.radar-chart {
margin-bottom: var(--space-6);
display: flex;
justify-content: center;
.radar-container {
width: 200px;
height: 200px;
position: relative;
border: 2px solid var(--border-light);
border-radius: 50%;
.radar-axis {
position: absolute;
width: 100%;
height: 100%;
.axis-label {
position: absolute;
font-size: var(--text-xs);
font-weight: 500;
padding: var(--space-1) var(--space-2);
background: var(--bg-primary);
border-radius: var(--radius-sm);
&.axis-recognition {
top: 0;
left: 50%;
transform: translateX(-50%);
}
&.axis-comprehension {
right: 0;
top: 50%;
transform: translateY(-50%);
}
&.axis-application {
bottom: 0;
left: 50%;
transform: translateX(-50%);
}
&.axis-speed {
left: 0;
top: 50%;
transform: translateY(-50%);
}
}
}
}
}
.ability-scores {
display: grid;
gap: var(--space-4);
.ability-item {
display: flex;
align-items: center;
gap: var(--space-4);
.ability-name {
min-width: 80px;
font-size: var(--text-sm);
color: var(--text-secondary);
}
.ability-bar {
flex: 1;
height: 8px;
background: var(--border-light);
border-radius: var(--radius-full);
overflow: hidden;
.ability-fill {
height: 100%;
border-radius: var(--radius-full);
transition: width 0.8s ease;
&.recognition {
background: var(--info);
}
&.comprehension {
background: var(--success);
}
&.application {
background: var(--primary);
}
&.speed {
background: var(--warning);
}
}
}
.ability-score {
min-width: 40px;
text-align: right;
font-weight: 600;
color: var(--text-primary);
}
}
}
}
.detailed-stats {
.section-title {
font-size: var(--text-xl);
font-weight: 600;
color: var(--text-primary);
margin: 0 0 var(--space-6) 0;
text-align: center;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--space-4);
.stat-card {
text-align: center;
padding: var(--space-6);
background: var(--bg-secondary);
border-radius: var(--radius-lg);
border: 1px solid var(--border-light);
.stat-icon {
font-size: var(--text-2xl);
margin-bottom: var(--space-2);
}
.stat-value {
font-size: var(--text-xl);
font-weight: 700;
color: var(--text-primary);
margin-bottom: var(--space-1);
}
.stat-label {
font-size: var(--text-sm);
color: var(--text-secondary);
}
}
}
}
.analysis-section {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-6);
.weakness-analysis,
.improvement-suggestions {
padding: var(--space-6);
background: var(--bg-secondary);
border-radius: var(--radius-lg);
.analysis-title {
font-size: var(--text-lg);
font-weight: 600;
color: var(--text-primary);
margin: 0 0 var(--space-4) 0;
}
.analysis-content {
color: var(--text-secondary);
line-height: 1.6;
}
.suggestions-list {
list-style: none;
padding: 0;
margin: 0;
.suggestion-item {
padding: var(--space-2) 0;
color: var(--text-secondary);
line-height: 1.5;
position: relative;
padding-left: var(--space-4);
&::before {
content: '•';
color: var(--primary);
position: absolute;
left: 0;
}
}
}
}
}
.rewards-section {
.section-title {
font-size: var(--text-xl);
font-weight: 600;
color: var(--text-primary);
margin: 0 0 var(--space-6) 0;
text-align: center;
}
.rewards-grid {
display: flex;
justify-content: center;
gap: var(--space-4);
.reward-item {
text-align: center;
padding: var(--space-6);
background: var(--bg-secondary);
border-radius: var(--radius-lg);
border: 2px solid transparent;
&.reward-diamond {
border-color: var(--primary);
background: var(--primary-light);
}
&.reward-experience {
border-color: var(--warning);
background: var(--warning-light);
}
.reward-icon {
font-size: var(--text-2xl);
margin-bottom: var(--space-2);
}
.reward-amount {
font-size: var(--text-lg);
font-weight: 700;
color: var(--text-primary);
margin-bottom: var(--space-1);
}
.reward-description {
font-size: var(--text-sm);
color: var(--text-secondary);
}
}
}
}
}
.results-actions {
display: flex;
justify-content: center;
gap: var(--space-4);
margin-top: var(--space-8);
.action-btn {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-6);
border: none;
border-radius: var(--radius-md);
font-size: var(--text-base);
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
.icon {
width: 20px;
height: 20px;
}
&.primary {
background: var(--primary);
color: white;
&:hover {
background: var(--primary-dark);
transform: translateY(-2px);
}
}
&.secondary {
background: var(--bg-secondary);
color: var(--text-secondary);
border: 1px solid var(--border-light);
&:hover {
background: var(--bg-tertiary);
border-color: var(--border-medium);
}
}
}
}
}
//
@media (max-width: 768px) {
.vocabulary-choice-results {
padding: var(--space-4);
.results-main {
.score-card {
flex-direction: column;
gap: var(--space-4);
padding: var(--space-6);
}
.detailed-stats .stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.analysis-section {
grid-template-columns: 1fr;
}
}
.results-actions {
flex-direction: column;
align-items: center;
.action-btn {
width: 100%;
max-width: 300px;
}
}
}
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,829 @@
<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>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,744 @@
<template>
<div class="vocabulary-view">
<div class="q-pa-md">
<!-- 快捷鍵提示 -->
<div v-if="showKeyboardHelp" class="keyboard-help fixed-top-right z-top">
<q-card class="bg-grey-9 text-white q-pa-sm">
<div class="text-caption q-mb-xs">快捷鍵:</div>
<div class="text-caption">Space: 播放/暫停 | → : 下一個 | ← : 上一個 | H: 顯示/隱藏幫助</div>
</q-card>
</div>
<!-- 主要內容區域 -->
<div class="row justify-center">
<div class="col-12 col-lg-10">
<!-- 頂部控制欄 -->
<div class="row q-mb-lg items-center">
<div class="col">
<div class="text-h4 q-mb-sm">詞彙學習</div>
<div class="text-body2 text-grey-6">{{ currentVocabulary ? `當前: ${currentVocabulary.word}` : '透過戲劇化情境學習新詞彙' }}</div>
</div>
<div class="col-auto">
<q-btn-group>
<q-btn
@click="toggleKeyboardHelp"
flat
icon="keyboard"
:color="showKeyboardHelp ? 'primary' : 'grey'"
tooltip="快捷鍵說明 (H)"
/>
<q-btn @click="toggleAutoPlay" flat :icon="autoPlay ? 'pause' : 'play_arrow'" :color="autoPlay ? 'negative' : 'positive'" tooltip="自動播放" />
<q-btn @click="resetProgress" flat icon="refresh" color="orange" tooltip="重置進度" />
</q-btn-group>
</div>
</div>
<!-- 詞彙卡片主區域 -->
<div class="row q-col-gutter-lg">
<!-- 左側 - 詞彙詳情 -->
<div class="col-12 col-md-8">
<q-card v-if="currentVocabulary" class="vocabulary-card q-mb-md" bordered>
<q-card-section class="text-center q-pb-none">
<div class="text-h3 text-primary q-mb-md">{{ currentVocabulary.word }}</div>
<div class="text-h6 text-grey-7 q-mb-sm">{{ currentVocabulary.pronunciation }}</div>
<!-- 音頻控制 -->
<div class="audio-controls q-mb-lg">
<q-btn
@click="playAudio"
:loading="audioLoading"
:disable="!currentVocabulary.audio"
color="primary"
icon="volume_up"
size="lg"
round
class="q-mr-md"
>
<q-tooltip>播放發音 (Space)</q-tooltip>
</q-btn>
<q-slider
v-model="playbackRate"
:min="0.5"
:max="2"
:step="0.1"
label
style="width: 200px;"
class="q-ml-md"
/>
<div class="text-caption q-ml-sm">語速: {{ playbackRate }}x</div>
</div>
</q-card-section>
<q-separator />
<q-card-section>
<!-- 詞性和定義 -->
<div class="row q-mb-md">
<div class="col-12">
<q-chip :label="currentVocabulary.partOfSpeech" color="secondary" text-color="white" />
<div class="text-h6 q-mt-sm">{{ currentVocabulary.definition }}</div>
<div class="text-body1 text-grey-8 q-mt-xs">{{ currentVocabulary.translation }}</div>
</div>
</div>
<!-- 例句 -->
<div class="examples-section">
<div class="text-subtitle1 q-mb-md">例句:</div>
<div v-for="(example, index) in currentVocabulary.examples" :key="index" class="example-item q-mb-md">
<q-card flat bordered class="q-pa-md">
<div class="row items-center">
<div class="col">
<div class="text-body1 q-mb-xs">{{ example.sentence }}</div>
<div class="text-body2 text-grey-7">{{ example.translation }}</div>
</div>
<div class="col-auto">
<q-btn
v-if="example.audio"
@click="playExampleAudio(example.audio)"
flat
round
icon="play_arrow"
size="sm"
/>
</div>
</div>
</q-card>
</div>
</div>
<!-- 標籤 -->
<div v-if="currentVocabulary.tags?.length" class="q-mt-md">
<div class="text-subtitle2 q-mb-xs">標籤:</div>
<q-chip
v-for="tag in currentVocabulary.tags"
:key="tag"
:label="tag"
outline
size="sm"
class="q-mr-xs"
/>
</div>
</q-card-section>
</q-card>
<!-- 學習進度卡片 -->
<q-card class="progress-card">
<q-card-section>
<div class="text-h6 q-mb-md">學習進度</div>
<div class="row q-col-gutter-md">
<div class="col-6">
<div class="text-center">
<q-circular-progress
:value="vocabularyProgress"
size="80px"
:thickness="0.15"
color="primary"
track-color="grey-3"
class="q-mb-md"
>
{{ Math.round(vocabularyProgress) }}%
</q-circular-progress>
<div class="text-caption">總進度</div>
</div>
</div>
<div class="col-6">
<div class="text-center">
<q-circular-progress
:value="masteryLevel"
size="80px"
:thickness="0.15"
color="positive"
track-color="grey-3"
class="q-mb-md"
>
{{ masteryLevel }}
</q-circular-progress>
<div class="text-caption">熟練度</div>
</div>
</div>
</div>
</q-card-section>
</q-card>
</div>
<!-- 右側 - 控制面板 -->
<div class="col-12 col-md-4">
<!-- 導航控制 -->
<q-card class="navigation-card q-mb-md">
<q-card-section>
<div class="text-h6 q-mb-md">導航</div>
<div class="text-center q-mb-md">
<div class="text-subtitle2">{{ currentIndex + 1 }} / {{ vocabularyList.length }}</div>
<q-linear-progress :value="(currentIndex + 1) / vocabularyList.length" color="primary" class="q-mt-xs" />
</div>
<div class="navigation-buttons">
<q-btn
@click="previousVocabulary"
:disable="currentIndex <= 0"
color="primary"
outline
icon="chevron_left"
label="上一個"
class="full-width q-mb-sm"
/>
<q-btn
@click="nextVocabulary"
:disable="currentIndex >= vocabularyList.length - 1"
color="primary"
icon-right="chevron_right"
label="下一個"
class="full-width"
/>
</div>
</q-card-section>
</q-card>
<!-- 練習模式 -->
<q-card class="practice-card q-mb-md">
<q-card-section>
<div class="text-h6 q-mb-md">練習模式</div>
<q-btn
@click="startChoicePractice"
color="secondary"
icon="quiz"
label="選擇題練習"
class="full-width q-mb-sm"
/>
<q-btn
@click="startMatchingPractice"
color="secondary"
icon="link"
label="圖片匹配"
class="full-width q-mb-sm"
/>
<q-btn
@click="startSentencePractice"
color="secondary"
icon="reorder"
label="句子重組"
class="full-width"
/>
</q-card-section>
</q-card>
<!-- 詞彙列表 -->
<q-card class="vocabulary-list-card">
<q-card-section>
<div class="text-h6 q-mb-md">詞彙列表</div>
<q-list separator>
<q-item
v-for="(vocab, index) in vocabularyList"
:key="vocab.id"
@click="selectVocabulary(index)"
:class="{ 'bg-primary text-white': index === currentIndex }"
clickable
v-ripple
>
<q-item-section>
<q-item-label>{{ vocab.word }}</q-item-label>
<q-item-label caption>{{ vocab.translation }}</q-item-label>
</q-item-section>
<q-item-section side>
<q-circular-progress
:value="(vocab.masteryLevel / 5) * 100"
size="20px"
:thickness="0.2"
:color="vocab.masteryLevel >= 4 ? 'positive' : vocab.masteryLevel >= 2 ? 'warning' : 'negative'"
track-color="grey-3"
/>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
</q-card>
</div>
</div>
</div>
</div>
<!-- 載入狀態 -->
<q-inner-loading :showing="isLoading">
<q-spinner-gears size="50px" color="primary" />
</q-inner-loading>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useLearningStore } from '@/stores/learning'
import type { VocabularyCard } from '@/types/learning'
import { useQuasar } from 'quasar'
import { useAudio } from '@/composables/useAudio'
import { useKeyboard } from '@/composables/useKeyboard'
const router = useRouter()
const learningStore = useLearningStore()
const $q = useQuasar()
// Composables
const audio = useAudio()
const keyboard = useKeyboard({ ignoreInputs: true })
// 狀態管理
const currentIndex = ref(0)
const showKeyboardHelp = ref(false)
const autoPlay = ref(false)
const isLoading = ref(false)
// 模擬詞彙數據 (後續從API獲取)
const vocabularyList = ref<VocabularyCard[]>([
{
id: '1',
word: 'dramatic',
pronunciation: '/drəˈmætɪk/',
definition: 'relating to drama or the performance of drama',
translation: '戲劇的;引人注目的',
partOfSpeech: 'adjective',
examples: [
{
sentence: 'The play had a very dramatic ending.',
translation: '這部戲有一個非常戲劇化的結局。',
audio: '/audio/examples/dramatic-example1.mp3'
},
{
sentence: 'She made a dramatic entrance to the party.',
translation: '她戲劇性地進入了派對。'
}
],
audio: '/audio/vocabulary/dramatic.mp3',
image: '/images/vocabulary/dramatic.jpg',
masteryLevel: 2,
lastReviewed: '2025-09-08T10:00:00Z',
nextReviewDate: '2025-09-11T10:00:00Z',
reviewCount: 5,
correctCount: 3,
tags: ['adjective', 'arts', 'performance']
},
{
id: '2',
word: 'linguistic',
pronunciation: '/lɪŋˈɡwɪstɪk/',
definition: 'relating to language or linguistics',
translation: '語言的;語言學的',
partOfSpeech: 'adjective',
examples: [
{
sentence: 'Her linguistic abilities are impressive.',
translation: '她的語言能力令人印象深刻。'
}
],
audio: '/audio/vocabulary/linguistic.mp3',
masteryLevel: 4,
lastReviewed: '2025-09-09T14:00:00Z',
reviewCount: 8,
correctCount: 7,
tags: ['adjective', 'language', 'academic']
},
{
id: '3',
word: 'immersive',
pronunciation: '/ɪˈːsɪv/',
definition: 'providing, involving, or characterized by deep absorption or immersion',
translation: '沉浸式的;身臨其境的',
partOfSpeech: 'adjective',
examples: [
{
sentence: 'The VR game provides an immersive experience.',
translation: 'VR遊戲提供沉浸式體驗。'
}
],
audio: '/audio/vocabulary/immersive.mp3',
masteryLevel: 1,
lastReviewed: '2025-09-09T16:00:00Z',
reviewCount: 2,
correctCount: 1,
tags: ['adjective', 'technology', 'experience']
}
])
// 計算屬性
const currentVocabulary = computed(() => {
return vocabularyList.value[currentIndex.value] || null
})
const vocabularyProgress = computed(() => {
if (vocabularyList.value.length === 0) return 0
const totalMastery = vocabularyList.value.reduce((sum, vocab) => sum + vocab.masteryLevel, 0)
return (totalMastery / (vocabularyList.value.length * 5)) * 100
})
const masteryLevel = computed(() => {
return currentVocabulary.value?.masteryLevel || 0
})
// 使用 composable 的狀態
const audioLoading = computed(() => audio.isLoading.value)
const playbackRate = computed({
get: () => audio.playbackRate.value,
set: (value: number) => audio.setPlaybackRate(value)
})
// 音頻播放功能
const playAudio = async () => {
if (!currentVocabulary.value?.audio) {
$q.notify({
type: 'warning',
message: '此詞彙沒有音頻文件'
})
return
}
try {
// 嘗試播放音頻,如果失敗則顯示模擬播放通知
const success = await audio.quickPlay(currentVocabulary.value.audio, {
playbackRate: audio.playbackRate.value
})
if (!success && audio.error.value) {
// 模擬播放成功 (用於演示)
$q.notify({
type: 'positive',
message: `正在播放: ${currentVocabulary.value.word}`,
caption: '模擬播放 - 實際項目中將播放真實音頻'
})
} else {
$q.notify({
type: 'positive',
message: `正在播放: ${currentVocabulary.value.word}`
})
}
} catch (error) {
console.error('播放音頻失敗:', error)
$q.notify({
type: 'negative',
message: '播放音頻失敗'
})
}
}
// 播放例句音頻
const playExampleAudio = async (audioUrl: string) => {
try {
const success = await audio.quickPlay(audioUrl, {
playbackRate: audio.playbackRate.value
})
if (!success) {
// 模擬播放例句
$q.notify({
type: 'info',
message: '正在播放例句',
caption: '模擬播放 - 實際項目中將播放真實音頻'
})
} else {
$q.notify({
type: 'info',
message: '正在播放例句'
})
}
} catch (error) {
console.error('播放例句音頻失敗:', error)
$q.notify({
type: 'negative',
message: '播放例句音頻失敗'
})
}
}
// 導航功能
const nextVocabulary = () => {
if (currentIndex.value < vocabularyList.value.length - 1) {
currentIndex.value++
if (autoPlay.value) {
setTimeout(() => playAudio(), 500)
}
}
}
const previousVocabulary = () => {
if (currentIndex.value > 0) {
currentIndex.value--
if (autoPlay.value) {
setTimeout(() => playAudio(), 500)
}
}
}
const selectVocabulary = (index: number) => {
currentIndex.value = index
if (autoPlay.value) {
setTimeout(() => playAudio(), 500)
}
}
// 控制功能
const toggleKeyboardHelp = () => {
showKeyboardHelp.value = !showKeyboardHelp.value
}
const toggleAutoPlay = () => {
autoPlay.value = !autoPlay.value
$q.notify({
type: 'info',
message: autoPlay.value ? '已開啟自動播放' : '已關閉自動播放'
})
}
const resetProgress = () => {
$q.dialog({
title: '重置進度',
message: '您確定要重置所有學習進度嗎?',
cancel: true,
persistent: true
}).onOk(() => {
vocabularyList.value.forEach(vocab => {
vocab.masteryLevel = 0
vocab.reviewCount = 0
vocab.correctCount = 0
})
$q.notify({
type: 'positive',
message: '學習進度已重置'
})
})
}
// 練習模式
const startChoicePractice = () => {
router.push('/learning/vocabulary/choice-practice')
}
const startMatchingPractice = () => {
router.push('/learning/vocabulary/matching-practice')
}
const startSentencePractice = () => {
router.push('/learning/vocabulary/sentence-practice')
}
// 初始化快捷鍵
const initKeyboardShortcuts = () => {
const shortcuts = [
{
key: 'Space',
code: 'Space',
description: '播放/暫停音頻',
action: playAudio
},
{
key: 'ArrowRight',
code: 'ArrowRight',
description: '下一個詞彙',
action: nextVocabulary
},
{
key: 'ArrowLeft',
code: 'ArrowLeft',
description: '上一個詞彙',
action: previousVocabulary
},
{
key: 'h',
code: 'KeyH',
description: '顯示/隱藏幫助',
action: toggleKeyboardHelp
},
{
key: 'a',
code: 'KeyA',
description: '切換自動播放',
action: toggleAutoPlay
},
{
key: 'r',
code: 'KeyR',
description: '重置進度',
action: resetProgress
}
]
// 添加數字快捷鍵 (1-9)
for (let i = 1; i <= 9; i++) {
shortcuts.push({
key: i.toString(),
code: `Digit${i}`,
description: `選擇第 ${i} 個詞彙`,
action: () => {
const index = i - 1
if (index < vocabularyList.value.length) {
selectVocabulary(index)
}
}
})
}
keyboard.registerMultiple(shortcuts)
}
// 監聽語速變化
watch(() => audio.playbackRate.value, (newRate) => {
console.log('語速調整為:', newRate)
})
// 生命週期
onMounted(() => {
// 初始化快捷鍵
initKeyboardShortcuts()
// 從store獲取詞彙數據 (如果有的話)
if (learningStore.vocabulary.length > 0) {
vocabularyList.value = learningStore.vocabulary
}
// 顯示快捷鍵幫助 (首次進入)
setTimeout(() => {
showKeyboardHelp.value = true
setTimeout(() => {
showKeyboardHelp.value = false
}, 3000)
}, 1000)
})
onUnmounted(() => {
// Composables 會自動清理資源
console.log('詞彙學習頁面已卸載')
})
</script>
<style scoped>
.vocabulary-view {
min-height: 100vh;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
}
.keyboard-help {
top: 20px;
right: 20px;
z-index: 1000;
}
.vocabulary-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
.vocabulary-card .text-primary {
color: white !important;
font-weight: bold;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.vocabulary-card .text-grey-7 {
color: rgba(255, 255, 255, 0.8) !important;
}
.vocabulary-card .q-separator {
background-color: rgba(255, 255, 255, 0.2);
}
.audio-controls {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 16px;
}
.example-item {
transition: transform 0.2s ease;
}
.example-item:hover {
transform: translateX(4px);
}
.progress-card {
background: linear-gradient(135deg, #ff9a9e 0%, #fecfef 100%);
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
.navigation-card {
background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
.practice-card {
background: linear-gradient(135deg, #d299c2 0%, #fef9d7 100%);
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
.vocabulary-list-card {
background: white;
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
max-height: 400px;
overflow-y: auto;
}
.navigation-buttons .q-btn {
text-transform: none;
font-weight: 500;
}
/* 響應式設計 */
@media (max-width: 768px) {
.keyboard-help {
position: fixed;
top: 10px;
left: 10px;
right: 10px;
width: auto;
}
.audio-controls {
flex-direction: column;
align-items: center;
}
.vocabulary-list-card {
max-height: 300px;
}
}
/* 動畫效果 */
.vocabulary-card {
animation: fadeInUp 0.6s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 滾動條樣式 */
.vocabulary-list-card::-webkit-scrollbar {
width: 6px;
}
.vocabulary-list-card::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.vocabulary-list-card::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.vocabulary-list-card::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
</style>

View File

@ -0,0 +1,354 @@
<template>
<div class="vocabulary-native">
<!-- 完全原生HTML結構不使用Quasar組件 -->
<div class="container">
<!-- 頁面標題 -->
<div class="page-header">
<h1 class="page-title">詞彙學習中心</h1>
<p class="page-subtitle">透過多種練習模式提升你的詞彙量</p>
</div>
<!-- 學習統計 -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-content">
<div class="stat-icon">📚</div>
<div class="stat-info">
<div class="stat-value">{{ vocabularyStore.vocabularies.length }}</div>
<div class="stat-label">總詞彙數</div>
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-content">
<div class="stat-icon"></div>
<div class="stat-info">
<div class="stat-value">{{ vocabularyStore.masteredWords.length }}</div>
<div class="stat-label">已掌握</div>
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-content">
<div class="stat-icon"></div>
<div class="stat-info">
<div class="stat-value">{{ vocabularyStore.wordsForReview.length }}</div>
<div class="stat-label">待複習</div>
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-content">
<div class="stat-icon">🎯</div>
<div class="stat-info">
<div class="stat-value">{{ vocabularyStore.learningWords.length }}</div>
<div class="stat-label">學習中</div>
</div>
</div>
</div>
</div>
<!-- 練習模式 -->
<div class="practice-section">
<h2 class="section-title">快速開始</h2>
<div class="practice-grid">
<div class="practice-card" @click="startPractice('multiple_choice_definition')">
<div class="practice-icon">🧠</div>
<h3>選擇題練習</h3>
<p class="practice-description">測試詞彙定義理解</p>
<div class="practice-meta">
<span class="chip chip-primary">10</span>
<span class="chip chip-outline">基礎-中級</span>
</div>
</div>
<div class="practice-card" @click="startPractice('multiple_choice_translation')">
<div class="practice-icon">🌐</div>
<h3>翻譯練習</h3>
<p class="practice-description">英中翻譯能力測試</p>
<div class="practice-meta">
<span class="chip chip-primary">10</span>
<span class="chip chip-outline">中級-高級</span>
</div>
</div>
<div class="practice-card" @click="startPractice('multiple_choice_synonym')">
<div class="practice-icon">🔄</div>
<h3>同義詞練習</h3>
<p class="practice-description">詞彙關聯性訓練</p>
<div class="practice-meta">
<span class="chip chip-primary">10</span>
<span class="chip chip-outline">高級</span>
</div>
</div>
</div>
<div class="button-container">
<button class="start-button" @click="goToCustomPractice">
自定義練習設定
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useVocabularyStore } from '@/stores/vocabulary'
import type { ExerciseType } from '@/types/vocabulary'
const router = useRouter()
const vocabularyStore = useVocabularyStore()
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')
}
onMounted(async () => {
await vocabularyStore.fetchVocabularies()
})
</script>
<style scoped>
/* 完全自定義樣式不依賴Quasar */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.vocabulary-native {
font-family: 'Inter', 'Noto Sans TC', -apple-system, BlinkMacSystemFont, sans-serif;
background: #F7F9FC;
color: #2C3E50;
line-height: 1.6;
min-height: 100vh;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
/* 頁面標題 */
.page-header {
text-align: center;
margin-bottom: 3rem;
}
.page-title {
font-size: 2.5rem;
font-weight: 800;
color: #2C3E50;
margin-bottom: 0.5rem;
}
.page-subtitle {
font-size: 1.25rem;
color: #64748B;
}
/* 統計卡片 */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 3rem;
}
.stat-card {
background: white;
border-radius: 1rem;
padding: 1.5rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px -3px rgba(0, 0, 0, 0.1);
}
.stat-content {
display: flex;
align-items: center;
gap: 1rem;
}
.stat-icon {
width: 3rem;
height: 3rem;
background: #00E5CC;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
}
.stat-info {
flex: 1;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
color: #2C3E50;
}
.stat-label {
font-size: 0.875rem;
color: #64748B;
}
/* 練習模式 */
.practice-section {
margin-bottom: 3rem;
}
.section-title {
font-size: 1.75rem;
font-weight: 700;
color: #2C3E50;
margin-bottom: 1.5rem;
}
.practice-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.practice-card {
background: white;
border-radius: 1rem;
padding: 2rem;
text-align: center;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
cursor: pointer;
border: 2px solid transparent;
}
.practice-card:hover {
transform: translateY(-4px);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
border-color: #00E5CC;
}
.practice-icon {
width: 4rem;
height: 4rem;
background: linear-gradient(135deg, #00E5CC, #6366F1);
border-radius: 1rem;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 1rem;
font-size: 2rem;
}
.practice-card h3 {
font-size: 1.25rem;
font-weight: 600;
color: #2C3E50;
margin-bottom: 0.5rem;
}
.practice-description {
font-size: 0.875rem;
color: #64748B;
margin-bottom: 1rem;
}
.practice-meta {
display: flex;
gap: 0.5rem;
justify-content: center;
flex-wrap: wrap;
}
.chip {
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.chip-primary {
background: #00E5CC;
color: white;
}
.chip-outline {
border: 1px solid #E2E8F0;
color: #64748B;
}
/* 按鈕 */
.button-container {
text-align: center;
}
.start-button {
background: linear-gradient(135deg, #00E5CC, #6366F1);
color: white;
border: none;
padding: 0.75rem 2rem;
border-radius: 0.75rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.start-button:hover {
transform: translateY(-1px);
box-shadow: 0 10px 25px -3px rgba(0, 229, 204, 0.5);
}
/* 響應式設計 */
@media (max-width: 768px) {
.container {
padding: 1rem;
}
.page-title {
font-size: 2rem;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.practice-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -0,0 +1,992 @@
<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>

View File

@ -0,0 +1,168 @@
<template>
<div class="shop-view">
<q-page class="q-pa-md">
<div class="text-center q-mb-lg">
<h1 class="text-h4 q-mb-sm">商店</h1>
<p class="text-body1 text-grey-7">購買學習套裝和功能</p>
</div>
<div class="row q-gutter-md">
<div class="col-12 col-md-4" v-for="item in shopItems" :key="item.id">
<q-card class="shop-item-card">
<q-card-section class="text-center">
<q-icon :name="item.icon" size="64px" :color="item.color" />
<div class="text-h6 q-mt-md">{{ item.name }}</div>
<div class="text-body2 text-grey-7 q-mt-sm">{{ item.description }}</div>
</q-card-section>
<q-card-section class="text-center">
<div class="price-display">
<span class="currency"></span>
<span class="amount">{{ item.price }}</span>
</div>
<div class="original-price" v-if="item.originalPrice">
原價 ${{ item.originalPrice }}
</div>
</q-card-section>
<q-card-actions class="justify-center">
<q-btn
color="primary"
:label="item.buttonText || '購買'"
@click="purchaseItem(item)"
class="full-width"
/>
</q-card-actions>
</q-card>
</div>
</div>
<div class="row justify-center q-mt-xl">
<div class="col-12 col-md-8">
<q-card class="subscription-banner">
<q-card-section class="row items-center">
<div class="col">
<div class="text-h6">升級到進階會員</div>
<p class="text-body2">獲得完整的學習體驗和限量功能</p>
</div>
<div class="col-auto">
<q-btn
color="secondary"
label="查看方案"
@click="$router.push({ name: 'subscription' })"
/>
</div>
</q-card-section>
</q-card>
</div>
</div>
</q-page>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const shopItems = ref([
{
id: 1,
name: '特級詞彙包',
description: '包含 500+ 常用詞彙和例句',
price: 99,
originalPrice: 199,
icon: 'local_library',
color: 'primary'
},
{
id: 2,
name: 'AI 導師加速器',
description: '享受更智能的 AI 輔導體驗',
price: 149,
icon: 'psychology',
color: 'purple'
},
{
id: 3,
name: '發音大師',
description: '專業發音緯正和指導',
price: 199,
icon: 'record_voice_over',
color: 'orange'
},
{
id: 4,
name: '學習加速器',
description: '雙倍經驗值加成',
price: 79,
icon: 'speed',
color: 'green'
},
{
id: 5,
name: '無限生命',
description: '練習時不再受限制',
price: 129,
icon: 'favorite',
color: 'red'
},
{
id: 6,
name: '特殊主題包',
description: '獨家界面主題和頭像框',
price: 59,
icon: 'palette',
color: 'pink'
}
])
const purchaseItem = (item: any) => {
console.log('購買項目:', item.name)
//
}
</script>
<style scoped>
.shop-view {
min-height: 100vh;
}
.shop-item-card {
height: 100%;
display: flex;
flex-direction: column;
}
.shop-item-card .q-card-section:last-of-type {
margin-top: auto;
}
.price-display {
display: flex;
align-items: baseline;
justify-content: center;
gap: 4px;
}
.currency {
font-size: 1.2em;
color: #666;
}
.amount {
font-size: 2.5em;
font-weight: bold;
color: #1976d2;
}
.original-price {
text-decoration: line-through;
color: #999;
font-size: 0.9em;
margin-top: 4px;
}
.subscription-banner {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
</style>

View File

@ -0,0 +1,242 @@
<template>
<div class="subscription-view">
<q-page class="q-pa-md">
<div class="text-center q-mb-lg">
<h1 class="text-h4 q-mb-sm">訂閱方案</h1>
<p class="text-body1 text-grey-7">選擇最適合您的學習方案</p>
</div>
<div class="row justify-center q-gutter-md">
<div
class="col-12 col-md-4"
v-for="plan in subscriptionPlans"
:key="plan.id"
>
<q-card
class="subscription-card"
:class="{ 'popular': plan.popular, 'selected': selectedPlan === plan.id }"
@click="selectedPlan = plan.id"
>
<q-card-section v-if="plan.popular" class="popular-badge">
<q-chip color="orange" text-color="white" icon="star">
最受歡迎
</q-chip>
</q-card-section>
<q-card-section class="text-center">
<div class="plan-name">{{ plan.name }}</div>
<div class="plan-price">
<span class="currency">$</span>
<span class="amount">{{ plan.price }}</span>
<span class="period">/</span>
</div>
<div class="plan-description">{{ plan.description }}</div>
</q-card-section>
<q-card-section>
<q-list dense>
<q-item v-for="feature in plan.features" :key="feature" class="q-px-none">
<q-item-section avatar class="min-width-auto">
<q-icon name="check_circle" color="positive" size="sm" />
</q-item-section>
<q-item-section>
<q-item-label class="text-body2">{{ feature }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
<q-card-section class="text-center">
<q-btn
:color="plan.popular ? 'orange' : 'primary'"
:label="selectedPlan === plan.id ? '已選擇' : '選擇此方案'"
:outline="selectedPlan !== plan.id"
class="full-width"
@click="selectPlan(plan)"
/>
</q-card-section>
</q-card>
</div>
</div>
<div class="row justify-center q-mt-xl" v-if="selectedPlan">
<div class="col-12 col-md-6">
<q-card class="checkout-card">
<q-card-section>
<div class="text-h6 q-mb-md">確認訂閱</div>
<div class="checkout-summary">
<div class="flex justify-between items-center">
<span>方案名稱</span>
<span class="font-medium">{{ getSelectedPlan()?.name }}</span>
</div>
<div class="flex justify-between items-center q-mt-sm">
<span>月費</span>
<span class="font-medium">${{ getSelectedPlan()?.price }}</span>
</div>
<q-separator class="q-my-md" />
<div class="flex justify-between items-center text-h6">
<span>總計</span>
<span class="text-primary">${{ getSelectedPlan()?.price }}</span>
</div>
</div>
</q-card-section>
<q-card-actions class="justify-center">
<q-btn
color="primary"
size="lg"
label="立即訂閱"
@click="subscribe"
class="full-width"
/>
</q-card-actions>
</q-card>
</div>
</div>
</q-page>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const selectedPlan = ref(2) //
const subscriptionPlans = ref([
{
id: 1,
name: '免費版',
price: 0,
description: '基本學習功能',
features: [
'每日 3 次免費練習',
'基礎詞彙學習',
'簡單對話練習',
'基本進度追蹤'
],
popular: false
},
{
id: 2,
name: '基礎版',
price: 99,
description: '完整學習體驗',
features: [
'無限練習次數',
'完整詞彙庫',
'進階對話練習',
'AI 個人化學習計劃',
'發音評估功能',
'學習進度分析'
],
popular: true
},
{
id: 3,
name: '進階版',
price: 199,
description: '最完整的學習方案',
features: [
'包含基礎版所有功能',
'專人 AI 導師',
'即時語音交流',
'專業發音糾正',
'客製化學習計劃',
'優先客戶服務'
],
popular: false
}
])
const selectPlan = (plan: any) => {
selectedPlan.value = plan.id
}
const getSelectedPlan = () => {
return subscriptionPlans.value.find(plan => plan.id === selectedPlan.value)
}
const subscribe = () => {
const plan = getSelectedPlan()
console.log('訂閱方案:', plan?.name)
//
}
</script>
<style scoped>
.subscription-view {
min-height: 100vh;
}
.subscription-card {
height: 100%;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
}
.subscription-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 25px rgba(0,0,0,0.15);
}
.subscription-card.popular {
border: 2px solid #ff9800;
}
.subscription-card.selected {
border: 2px solid #1976d2;
}
.popular-badge {
position: absolute;
top: -10px;
right: 16px;
z-index: 1;
}
.plan-name {
font-size: 1.5em;
font-weight: bold;
margin-bottom: 16px;
}
.plan-price {
display: flex;
align-items: baseline;
justify-content: center;
gap: 4px;
margin-bottom: 16px;
}
.plan-price .currency {
font-size: 1.2em;
color: #666;
}
.plan-price .amount {
font-size: 3em;
font-weight: bold;
color: #1976d2;
}
.plan-price .period {
font-size: 1em;
color: #666;
}
.plan-description {
color: #666;
margin-bottom: 16px;
}
.checkout-card {
background: #f8f9fa;
}
.checkout-summary {
background: white;
padding: 16px;
border-radius: 8px;
}
</style>

View File

@ -1,196 +0,0 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>功能測試 - Drama Ling 詞彙學習</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
margin: 40px;
line-height: 1.6;
}
.test-section {
margin-bottom: 30px;
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
}
button {
padding: 10px 20px;
margin: 5px;
background: #00e5cc;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
}
button:hover {
background: #00b8a0;
}
.status {
padding: 10px;
margin: 10px 0;
border-radius: 5px;
font-weight: 600;
}
.success { background: #d4edda; color: #155724; }
.error { background: #f8d7da; color: #721c24; }
.info { background: #cce7ff; color: #004085; }
</style>
</head>
<body>
<h1>🧪 Drama Ling 功能測試頁面</h1>
<div class="test-section">
<h2>📚 基礎模組測試</h2>
<button onclick="testModuleImports()">測試模組導入</button>
<button onclick="testStateManagement()">測試狀態管理</button>
<div id="moduleTest"></div>
</div>
<div class="test-section">
<h2>🔊 語音播放測試</h2>
<button onclick="testAudioSupport()">檢查語音支援</button>
<button onclick="testWordPronunciation()">測試單字發音</button>
<div id="audioTest"></div>
</div>
<div class="test-section">
<h2>💾 資料持久化測試</h2>
<button onclick="testLocalStorage()">測試本地儲存</button>
<button onclick="clearStorageData()">清空儲存資料</button>
<div id="storageTest"></div>
</div>
<div class="test-section">
<h2>🎯 應用程式載入</h2>
<button onclick="loadMainApp()">載入主應用</button>
<div id="appTest"></div>
</div>
<script type="module">
// Import modules for testing
import { VocabularyState } from './src/modules/VocabularyState.js';
import { VocabularyApp } from './src/modules/VocabularyApp.js';
import { AudioManager } from './src/utils/AudioManager.js';
// Make available globally for button clicks
window.testModules = { VocabularyState, VocabularyApp, AudioManager };
// Test functions
window.testModuleImports = () => {
const result = document.getElementById('moduleTest');
try {
const state = new window.testModules.VocabularyState();
const app = new window.testModules.VocabularyApp(state);
const audio = new window.testModules.AudioManager();
result.innerHTML = '<div class="status success">✅ 所有模組成功導入</div>';
console.log('Modules loaded:', { state, app, audio });
} catch (error) {
result.innerHTML = `<div class="status error">❌ 模組導入失敗: ${error.message}</div>`;
console.error('Module import error:', error);
}
};
window.testStateManagement = () => {
const result = document.getElementById('moduleTest');
try {
const state = new window.testModules.VocabularyState();
const words = state.getAllWords();
const progress = state.getProgress();
result.innerHTML += `
<div class="status success">
✅ 狀態管理正常<br>
📊 詞彙數量: ${words.length}<br>
🎯 學習進度: 已學習 ${progress.learned}, 今日新增 ${progress.todayNew}, 掌握率 ${progress.masteryRate}%
</div>
`;
} catch (error) {
result.innerHTML += `<div class="status error">❌ 狀態管理測試失敗: ${error.message}</div>`;
}
};
window.testAudioSupport = async () => {
const result = document.getElementById('audioTest');
try {
const audio = new window.testModules.AudioManager();
const isSupported = audio.isSupported();
if (isSupported) {
await audio.init();
const voice = audio.getCurrentVoice();
result.innerHTML = `
<div class="status success">
✅ 語音合成支援正常<br>
🎤 當前語音: ${voice ? voice.name : '預設語音'}<br>
🌍 語言: ${voice ? voice.lang : '未知'}
</div>
`;
} else {
result.innerHTML = '<div class="status error">❌ 此瀏覽器不支援語音合成</div>';
}
} catch (error) {
result.innerHTML = `<div class="status error">❌ 語音測試失敗: ${error.message}</div>`;
}
};
window.testWordPronunciation = async () => {
const result = document.getElementById('audioTest');
try {
const audio = new window.testModules.AudioManager();
result.innerHTML += '<div class="status info">🔊 播放測試詞彙 "confidence"...</div>';
await audio.speakWord('confidence');
result.innerHTML += '<div class="status success">✅ 語音播放完成</div>';
} catch (error) {
result.innerHTML += `<div class="status error">❌ 語音播放失敗: ${error.message}</div>`;
}
};
window.testLocalStorage = () => {
const result = document.getElementById('storageTest');
try {
const testData = { test: 'drama-ling-test', timestamp: Date.now() };
localStorage.setItem('test-data', JSON.stringify(testData));
const retrieved = JSON.parse(localStorage.getItem('test-data'));
if (retrieved.test === testData.test) {
result.innerHTML = '<div class="status success">✅ 本地儲存功能正常</div>';
localStorage.removeItem('test-data');
} else {
throw new Error('資料不匹配');
}
} catch (error) {
result.innerHTML = `<div class="status error">❌ 本地儲存測試失敗: ${error.message}</div>`;
}
};
window.clearStorageData = () => {
const result = document.getElementById('storageTest');
localStorage.removeItem('dramaling-vocabulary');
result.innerHTML += '<div class="status info">🗑️ 詞彙學習資料已清除</div>';
};
window.loadMainApp = () => {
const result = document.getElementById('appTest');
result.innerHTML = '<div class="status info">🚀 正在載入主應用...</div>';
// Redirect to main app
setTimeout(() => {
window.location.href = '/';
}, 1000);
};
// Auto-run basic tests on load
document.addEventListener('DOMContentLoaded', () => {
console.log('🧪 Test page loaded, running basic module test...');
setTimeout(() => {
window.testModuleImports();
}, 500);
});
</script>
</body>
</html>

50
apps/web/tsconfig.json Normal file
View File

@ -0,0 +1,50 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"~/*": ["src/*"],
"@components/*": ["src/components/*"],
"@modules/*": ["src/modules/*"],
"@stores/*": ["src/stores/*"],
"@services/*": ["src/services/*"],
"@utils/*": ["src/utils/*"],
"@assets/*": ["src/assets/*"]
},
/* Vue */
"types": ["node"],
"allowJs": true
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue",
"tests/**/*.ts"
],
"exclude": [
"node_modules",
"dist"
]
}

View File

@ -1,31 +0,0 @@
import { defineConfig } from 'vite'
import legacy from '@vitejs/plugin-legacy'
export default defineConfig({
plugins: [
legacy({
targets: ['defaults', 'not IE 11']
})
],
server: {
port: 3000,
open: true
},
build: {
outDir: 'dist',
minify: 'terser',
sourcemap: true,
rollupOptions: {
output: {
manualChunks: {
// No external dependencies to chunk
}
}
}
},
resolve: {
alias: {
'@': new URL('./src', import.meta.url).pathname
}
}
})

190
apps/web/vite.config.ts Normal file
View File

@ -0,0 +1,190 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { VitePWA } from 'vite-plugin-pwa'
import Components from 'unplugin-vue-components/vite'
import AutoImport from 'unplugin-auto-import/vite'
import { quasar, transformAssetUrls } from '@quasar/vite-plugin'
import path from 'path'
export default defineConfig({
plugins: [
vue({
template: { transformAssetUrls }
}),
quasar({
// sassVariables: 'src/assets/styles/quasar-variables.sass'
}),
Components({
resolvers: [
(componentName) => {
if (componentName.startsWith('Q'))
return { name: componentName, from: 'quasar' }
}
],
dts: true,
dirs: ['src/components'],
extensions: ['vue'],
deep: true
}),
AutoImport({
imports: [
'vue',
'vue-router',
'pinia',
{
'quasar': ['useQuasar', '$q', 'Notify', 'Loading', 'Dialog'],
'@vueuse/core': ['useLocalStorage', 'useSessionStorage', 'useFetch']
}
],
dts: true,
dirs: ['src/composables', 'src/stores'],
vueTemplate: true
}),
VitePWA({
registerType: 'autoUpdate',
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
skipWaiting: true,
clientsClaim: true,
runtimeCaching: [
{
urlPattern: /^https:\/\/api\..*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
networkTimeoutSeconds: 10,
expiration: {
maxEntries: 50,
maxAgeSeconds: 5 * 60, // 5 minutes
},
cacheableResponse: {
statuses: [0, 200],
},
},
},
{
urlPattern: /\.(?:png|gif|jpg|jpeg|svg|webp)$/,
handler: 'CacheFirst',
options: {
cacheName: 'images-cache',
expiration: {
maxEntries: 100,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
},
},
},
],
},
manifest: {
name: 'Drama Ling - 戲劇式語言學習',
short_name: 'Drama Ling',
description: '透過情境對話和互動練習學習語言的 AI 驅動應用程式',
theme_color: '#00E5CC',
background_color: '#1A1A1A',
display: 'standalone',
orientation: 'portrait',
scope: '/',
start_url: '/learning',
categories: ['education', 'productivity'],
lang: 'zh-TW',
screenshots: [
{
src: '/icons/screenshot-wide.png',
sizes: '1280x720',
type: 'image/png',
form_factor: 'wide',
label: 'Drama Ling 學習介面'
}
],
shortcuts: [
{
name: '詞彙學習',
short_name: '詞彙',
description: '開始詞彙練習',
url: '/learning/vocabulary',
icons: [{ src: '/icons/shortcut-vocabulary.png', sizes: '96x96' }]
},
{
name: '智能複習',
short_name: '複習',
description: '進行智能複習',
url: '/learning/vocabulary/review',
icons: [{ src: '/icons/shortcut-review.png', sizes: '96x96' }]
}
],
icons: [
{
src: '/favicon.svg',
sizes: 'any',
type: 'image/svg+xml'
},
{
src: '/icons/icon-192x192.svg',
sizes: '192x192',
type: 'image/svg+xml'
},
{
src: '/icons/icon-512x512.svg',
sizes: '512x512',
type: 'image/svg+xml'
}
]
},
devOptions: {
enabled: false, // 只在生產環境啟用
type: 'module',
navigateFallback: 'index.html'
}
})
],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
'~': path.resolve(__dirname, 'src'),
'@components': path.resolve(__dirname, 'src/components'),
'@modules': path.resolve(__dirname, 'src/modules'),
'@stores': path.resolve(__dirname, 'src/stores'),
'@services': path.resolve(__dirname, 'src/services'),
'@utils': path.resolve(__dirname, 'src/utils'),
'@assets': path.resolve(__dirname, 'src/assets')
}
},
css: {
preprocessorOptions: {
scss: {
additionalData: `@import "@/assets/styles/variables.scss";`
}
}
},
build: {
target: 'es2020',
rollupOptions: {
output: {
manualChunks: {
'vue-vendor': ['vue', 'vue-router', 'pinia'],
'quasar-vendor': ['quasar'],
'utils-vendor': ['axios', 'lodash-es', 'dayjs', '@vueuse/core'],
'validation-vendor': ['vee-validate', 'yup']
}
}
}
},
server: {
host: '0.0.0.0',
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:5000',
changeOrigin: true
}
}
}
})

View File

@ -21,15 +21,15 @@ docs/
### 🚀 `/00_starter` - 專案基礎
**用途**: 包含專案初始化和AI輔助開發所使用的基礎模板和提示詞。
| 檔案名稱 | 用途 |
| ------------------------------------- | ------------------------------ |
| `CLAUDE_TEMPLATE.md` | Claude AI 互動模板和專案設置 |
| `READ.md` | 使用入門模板的說明指引 |
| `business_function_design_prompt.md` | 生成業務功能設計的 AI 提示詞 |
| `generate_requirements_prompt.md` | 創建專案需求的 AI 提示詞 |
| `generate_system_structure_prompt.md` | 系統架構生成的 AI 提示詞 |
| `system_detail_prompt.md` | 詳細系統規格的 AI 提示詞 |
| `system_structured_schema.json` | 結構化系統設計輸出的 JSON 架構 |
| 檔案名稱 | 用途 |
|------|---------|
| `CLAUDE_TEMPLATE.md` | Claude AI 互動模板和專案設置 |
| `READ.md` | 使用入門模板的說明指引 |
| `business_function_design_prompt.md` | 生成業務功能設計的 AI 提示詞 |
| `generate_requirements_prompt.md` | 創建專案需求的 AI 提示詞 |
| `generate_system_structure_prompt.md` | 系統架構生成的 AI 提示詞 |
| `system_detail_prompt.md` | 詳細系統規格的 AI 提示詞 |
| `system_structured_schema.json` | 結構化系統設計輸出的 JSON 架構 |
**使用時機**: 這些檔案主要在專案初始化時使用,以及與 AI 助手協作生成文檔和程式碼結構時使用。
@ -38,13 +38,13 @@ docs/
### 📋 `/01_requirement` - 需求文檔
**用途**: 包含核心專案需求、規格說明和系統設計文檔。**專注於知識管理和規格定義**。
| 檔案名稱 | 用途 |
| ------------------------------ | ----------------------------------------------------------------- |
| `founding_pitch.md` | 初始專案提案和商業案例 |
| `requirements.md` | **產品功能需求總覽** - 詳細的產品規格和功能概述 |
| `user-stories.md` | **用戶故事和使用場景** - 用戶需求和互動情境 |
| `business-rules.md` | **業務邏輯和規則定義** - 核心商業規則和流程 |
| `acceptance-criteria.md` | **驗收標準和測試條件** - 功能驗收和品質標準 |
| 檔案名稱 | 用途 |
|------|---------|
| `founding_pitch.md` | 初始專案提案和商業案例 |
| `requirements.md` | **產品功能需求總覽** - 詳細的產品規格和功能概述 |
| `user-stories.md` | **用戶故事和使用場景** - 用戶需求和互動情境 |
| `business-rules.md` | **業務邏輯和規則定義** - 核心商業規則和流程 |
| `acceptance-criteria.md` | **驗收標準和測試條件** - 功能驗收和品質標準 |
| `system_structure_design.json` | **結構化系統設計** - 從需求生成包含模組、功能和UI視圖的JSON格式 |
**關鍵文檔**: `requirements.md` 是產品應該做什麼以及如何運作的唯一真實來源。
@ -54,20 +54,20 @@ docs/
### 🎨 `/02_design` - 設計規格 (更新 2025-09-09)
**用途**: 涵蓋使用者體驗、視覺設計和互動模式的文檔。**專注於知識管理和規格定義**。
| 檔案名稱 | 用途 |
| ---------------------------- | --------------------------------------------------- |
| `prototype-design-plan.md` | **原型設計製作計劃** - 雛形畫面開發的完整規劃 |
| `function-specs/` | **平台別功能規格** - mobile/web/common功能詳細規格 |
| `prototypes/` | **HTML原型系統** - 可互動的功能演示界面 |
| `ui-ux/` | **UI/UX設計系統** - 視覺規範、組件庫、樣式指南 |
| `views/` | **UI視圖設計檔案** - 介面設計的視覺化參考 |
**實際子目錄結構**:
- `function-specs/common/` - 跨平台共用規格API、資料模型、業務規則等
- `function-specs/mobile/` - 行動端專用功能規格
- `function-specs/web/` - 網頁端專用功能規格
- `ui-ux/ui-ux-guidelines.md` - 統一的UI/UX設計規範
- `ui-ux/dramaling-ui.css` - Drama Ling設計系統樣式表
| 檔案名稱 | 用途 |
|------|---------|
| `ui-specifications.md` | **UI設計規範和標準** - 視覺設計標準和介面規範 |
| `ux-guidelines.md` | **用戶體驗設計指南** - 互動模式和使用者流程 |
| `component-library.md` | **UI組件庫文檔** - 可重用組件和設計系統 |
| `design-tokens.md` | **設計令牌和主題系統** - 顏色、字體、間距等設計變量 |
| `ai-algorithm-specs.md` | AI 分析演算法和語言處理規格 |
| `business-logic-rules.md` | 核心商業規則和邏輯流程定義 |
| `content-management-specs.md` | 內容創建、策劃和管理工作流程 |
| `gamification-mechanics.md` | 遊戲元素、成就和獎勵系統設計 |
| `ui-ux-guidelines.md` | 視覺設計標準、組件庫和使用者介面指南 |
| `function-specs/` | 平台別功能規格mobile/web/common|
| `html-prototypes/` | HTML原型和頁面範例 |
| `views/` | UI視圖設計檔案 |
**目標讀者**: 設計師、前端開發人員和產品經理。
@ -76,14 +76,14 @@ docs/
### 👨‍💻 `/03_development` - 開發文檔 (更新 2025-09-09)
**用途**: 為開發人員提供編碼標準、工作流程和專案路線圖的指南。**專注於知識管理和規格定義**。
| 檔案名稱 | 用途 |
| -------------------------- | ----------------------------------------------------------------------------- |
| `coding-standards.md` | **程式碼規範** - Flutter/Dart 和 .NET/C# 的程式碼風格指南、命名慣例和最佳實踐 |
| `architecture-overview.md` | **系統架構概述** - 整體系統架構和設計決策說明 |
| `deployment-guide.md` | **部署流程文檔** - 部署步驟、環境配置和發布流程 |
| `troubleshooting.md` | **常見問題排除** - 開發過程中常見問題的解決方案 |
| `development-workflow.md` | Git 工作流程、分支策略、程式碼審查流程和開發生命週期 |
| `project-roadmap.md` | **開發時程表** - 階段、里程碑和功能交付時程 |
| 檔案名稱 | 用途 |
|------|---------|
| `coding-standards.md` | **程式碼規範** - Flutter/Dart 和 .NET/C# 的程式碼風格指南、命名慣例和最佳實踐 |
| `architecture-overview.md` | **系統架構概述** - 整體系統架構和設計決策說明 |
| `deployment-guide.md` | **部署流程文檔** - 部署步驟、環境配置和發布流程 |
| `troubleshooting.md` | **常見問題排除** - 開發過程中常見問題的解決方案 |
| `development-workflow.md` | Git 工作流程、分支策略、程式碼審查流程和開發生命週期 |
| `project-roadmap.md` | **開發時程表** - 階段、里程碑和功能交付時程 |
**目標讀者**: 所有參與專案的開發人員。
@ -92,19 +92,19 @@ docs/
### ⚙️ `/04_technical` - 技術規格 (更新 2025-09-09)
**用途**: 技術實作細節、系統架構和整合規格說明。**專注於知識管理和規格定義**。
| 子目錄/檔案 | 用途 |
| -------------------------- | ----------------------------------------------------- |
| `api-specifications.md` | **API接口文檔** - 完整API規格、端點定義和資料格式 |
| `database-schema.md` | **資料庫設計文檔** - 資料表結構、關聯和索引設計 |
| `security-requirements.md` | **安全性需求** - 安全標準、認證機制和資料保護 |
| `performance-standards.md` | **效能標準定義** - 效能指標、基準測試和優化準則 |
| `01_architecture/` | 系統架構設計和決策文檔 |
| `02_api/` | **REST API 文檔** - 完整API規格、端點文檔、Swagger UI |
| `03_frontend/` | 前端技術規格和實作指南 |
| `04_mobile/` | 移動端開發技術規格 |
| `05_deployment/` | 部署流程和環境配置 |
| `06_development/` | **開發過程管理** - 問題追蹤、環境設定和開發工具配置 |
| `07_planning/` | 技術規劃和決策記錄 |
| 子目錄/檔案 | 用途 |
|------|---------|
| `api-specifications.md` | **API接口文檔** - 完整API規格、端點定義和資料格式 |
| `database-schema.md` | **資料庫設計文檔** - 資料表結構、關聯和索引設計 |
| `security-requirements.md` | **安全性需求** - 安全標準、認證機制和資料保護 |
| `performance-standards.md` | **效能標準定義** - 效能指標、基準測試和優化準則 |
| `01_architecture/` | 系統架構設計和決策文檔 |
| `02_api/` | **REST API 文檔** - 完整API規格、端點文檔、Swagger UI |
| `03_frontend/` | 前端技術規格和實作指南 |
| `04_mobile/` | 移動端開發技術規格 |
| `05_deployment/` | 部署流程和環境配置 |
| `06_development/` | 開發環境設定和工具 |
| `07_planning/` | 技術規劃和決策記錄 |
**關鍵文檔**: `02_api/` 目錄中的API文檔作為前端和後端團隊之間的契約。
@ -135,15 +135,15 @@ docs/
### ✅ 正確的內容分層
| 內容類型 | 正確位置 |
| ------------------- | ---------------------- |
| 產品規格和需求 | `docs/01_requirement/` |
| 設計標準和指南 | `docs/02_design/` |
| 技術架構和 API 規格 | `docs/04_technical/` |
| 編碼規範和流程 | `docs/03_development/` |
| 具體任務和待辦事項 | `TASKS.md` |
| 專案執行計畫 | `projects/[專案名].md` |
| 進度追蹤和狀態更新 | 專案管理工具 |
| 內容類型 | 正確位置 |
|---------|----------|
| 產品規格和需求 | `docs/01_requirement/` |
| 設計標準和指南 | `docs/02_design/` |
| 技術架構和 API 規格 | `docs/04_technical/` |
| 編碼規範和流程 | `docs/03_development/` |
| 具體任務和待辦事項 | `TASKS.md` |
| 專案執行計畫 | `projects/[專案名].md` |
| 進度追蹤和狀態更新 | 專案管理工具 |
---
@ -176,7 +176,7 @@ docs/
- 主要文檔: `/02_design/ui-ux-guidelines.md`, `/02_design/gamification-mechanics.md`
- 內容策略: `/02_design/content-management-specs.md`
- 功能規格: `/02_design/function-specs/`
- 原型參考: `/02_design/prototypes/`
- 原型參考: `/02_design/html-prototypes/`
---
@ -198,16 +198,16 @@ docs/
## 🔍 快速參考
| 尋找... | 前往... |
| ------------------ | ----------------------------------------- |
| 要建構什麼功能 | `/01_requirement/requirements.md` |
| API 端點和資料格式 | `/04_technical/02_api/` |
| 系統架構 | `/04_technical/01_architecture/` |
| UI 設計標準 | `/02_design/ui-ux-guidelines.md` |
| 如何貢獻程式碼 | `/03_development/development-workflow.md` |
| 開發時程表 | `/03_development/project-roadmap.md` |
| 功能規格 | `/02_design/function-specs/` |
| 部署流程 | `/04_technical/05_deployment/` |
| 尋找... | 前往... |
|----------------|----------|
| 要建構什麼功能 | `/01_requirement/requirements.md` |
| API 端點和資料格式 | `/04_technical/02_api/` |
| 系統架構 | `/04_technical/01_architecture/` |
| UI 設計標準 | `/02_design/ui-ux-guidelines.md` |
| 如何貢獻程式碼 | `/03_development/development-workflow.md` |
| 開發時程表 | `/03_development/project-roadmap.md` |
| 功能規格 | `/02_design/function-specs/` |
| 部署流程 | `/04_technical/05_deployment/` |
---
@ -221,4 +221,4 @@ docs/
---
**最後更新**: 2025-09-10 ✅
**版本**: 3.0.1 - 重新定義06_development目錄職責明確開發過程管理範疇 (2025-09-10)
**版本**: 3.0.0 - 整合文檔層規範,明確定義文檔職責和禁止內容 (2025-09-10)

View File

@ -24,34 +24,6 @@
- 用戶可透過客服申請帳戶刪除後重新註冊
```
#### BR-USER-01: 付費用戶分級規則
```yaml
規則名稱: 用戶付費等級與權益管理
適用範圍: 所有付費用戶類別
用戶等級定義:
試用用戶:
- 期限: 7天免費體驗訂閱用戶
- 權益: 完整功能體驗
- 限制: 試用期結束後自動轉訂閱用戶
- 轉換: 若不訂閱,需自行到設定取消訂閱
訂閱用戶:
- 定價: NT$600/月 或 NT$6,000/年 (8.3折優惠)
- 權益: 無限制學習次數,進階統計報告
- 特權: 每日3次免費限時挑戰命條恢復加速
- 期限: 按月/年自動續訂,可隨時取消
進階用戶(第二階段功能開放後提供):
- 定價: NT$900/月 或 NT$9,000/年 (8.3折優惠)
- 權益: 訂閱用戶所有功能 + 進階自訂學習功能 + 更優質的學習體驗(tts)
- 特權: 更多命條上限,更快回復速度,專屬學習模式
- 階段: 第二階段功能開放後提供
高價值用戶(第三階段功能開放後提供):
- 定義: 累計購買鑽石超過NT$3,000的用戶
- 權益: VIP客服支援專屬活動邀請
- 特權: 新功能優先體驗,限定道具折扣,獲得限定道具
```
#### BR-AUTH-02: 密碼安全規則
```yaml
規則名稱: 密碼複雜度要求
@ -139,8 +111,9 @@
- 用戶初始生命值為5條
- 答錯或失敗會消耗1條生命
- 生命值為0時無法進行新的學習活動
- 每4小時自動回復1條生命最多回復到5條
- 每6小時自動回復1條生命最多回復到5條
生命恢復:
- 付費用戶生命回復速度提升至4小時1條
- 可使用鑽石立即購買生命(50鑽石=1條生命)
- 完成每日任務獎勵1條生命
- 觀看廣告可獲得1條生命(每日最多3次)
@ -185,12 +158,14 @@
規則名稱: 詞彙掌握度評估
適用範圍: 所有詞彙學習活動
規則內容:
- 起始點: 新詞彙 0% 掌握度
- 成功獎勵: +20% 掌握度
- 錯誤懲罰: -5% 掌握度
- 掌握標準: 80% 以上視為已掌握
- 新詞彙初始掌握度為0%
- 正確使用一次增加20%掌握度
- 錯誤使用一次減少10%掌握度
- 掌握度80%以上視為已掌握
複習機制:
- 複習時間 = 最近複習日期 + (2^成功次數) 天
- 掌握度<50%: 24小時後複習
- 掌握度50-79%: 3天後複習
- 掌握度80%+: 7天後複習
- 連續3次正確可延長複習間隔
```
@ -226,18 +201,38 @@
- 專精(C2): 學術表達與文化語境
```
### ⏰ 時間與限制
#### BR-TIME-01: 限時挑戰規則
```yaml
規則名稱: 300秒挑戰機制
適用範圍: 限時挑戰模式
規則內容:
- 每次挑戰固定300秒(5分鐘)
- 需要消耗1張挑戰門票
- 時間結束立即停止,不可延長
- 成績根據正確率和剩餘時間計算
門票機制:
- 免費用戶每日獲得2張門票
- 付費用戶每日獲得5張門票
- 可用鑽石購買額外門票(100鑽石/張)
- 門票不累積,當日未用完隔日重置
```
#### BR-TIME-02: 學習會話時限
```yaml
規則名稱: 學習會話超時處理
適用範圍: 所有學習活動會話
規則內容:
- 單次學習會話最長5分鐘
- 5分鐘後自動結束
- 會話結束會自動結算,並存到紀錄
- 單次學習會話最長2小時
- 30分鐘無操作自動暫停
- 暫停狀態保持30分鐘後自動結束
- 會話結束自動保存當前進度
數據保存:
- 已完成的練習立即保存
- 進行中的練習保存狀態
- 學習時間準確記錄
- 經驗值和獎勵結算
- 經驗值和獎勵延遲結算
```
### 🤝 社群互動
@ -263,14 +258,15 @@
規則名稱: 競爭排名計算
適用範圍: 所有排行榜功能
規則內容:
- 排行榜分為好友榜
- 排行榜分為好友榜和全球榜
- 每週一凌晨重置週排行榜
- 每月1號重置月排行榜
- 年度排行榜保持全年累積
排名計算:
- 主要依據: 遊玩關卡所獲得的經驗值
- 主要依據: 學習時間 × 正確率 × 連續天數加成
- 相同分數按學習開始時間排序
- 作弊或異常數據將被排除
- 排行榜前10名獲得特殊獎勵
```
### 🛡️ 安全與隱私

View File

@ -16,9 +16,8 @@
- **進階者** - 語言程度C1-C2精進專業溝通
#### 💰 付費用戶 (Premium User)
- **試用用戶** - 7天免費體驗訂閱期間
- **試用用戶** - 7天免費體驗期間
- **訂閱用戶** - 月費/年費訂閱會員
- **進階用戶** - 除了基礎功能,還有更多自訂學習功能可使用
- **高價值用戶** - 大量購買鑽石和道具
#### 🎯 目標導向用戶

View File

@ -62,8 +62,6 @@ https://docs.google.com/spreadsheets/d/1HiiqBKFF3cw73TNaCb0Xf3fTmg8Wefi5qVEVrXIy
時光關卡

View File

@ -1,67 +1,33 @@
# AI 對話分析算法規格
## 概述
定義 Drama Ling 應用中 AI 對話分析系統的具體實現方案,包含語法、語用、口說三維度評分邏輯。
定義 Drama Ling 應用中 AI 對話分析系統的具體實現方案,包含語法、語意、流暢度三維度評分邏輯。
## 核心評分維度
基於 UI_LevelResult_ScoreSummary.png 的實際設計,採用三維對話評估指標顯示:
基於 UI_LevelResult_ScoreSummary.png 的實際設計,採用簡化的三維評分顯示:
### 三維對話評估系統
### 簡化評分系統
**目標**: 提供清楚的學習反饋,觸發對應成就獎勵
#### 三維對話評分指標
#### 三評分指標
- [ ] **語法評分**: 評估語法正確性
- 所有對話皆正確時即為完美表現,若有藉由錯誤訂正功能將全部錯誤訂正完畢,則一樣給予完美表現
- 獲得完美表現則過關獎勵雙倍
- 過關獎勵: +1 鑽石 + 10 XP
- 達到優秀標準時觸發「完美語法」成就
- 獎勵: +10 鑽石 + 10 閃電能量
- [ ] **語用分析**: 評估內容理解和表達適切性(只建議,不評分)
- [ ] **語意評分**: 評估內容理解和表達適切性
- 作為整體表現參考
- 不單獨觸發特定成就
- [ ] **口說評分**: 評估口說表達的自然度和流暢性
- 評分標準:
- 🗣️ **發音評分 (Pronunciation)**: 音素準確度、尾音收口、鼻音共鳴
- ✅ **完整度評分 (Completeness)**: 句子完整性、遺漏詞彙檢測
- 📈 **流暢度評分 (Fluency)**: 語速自然度、停頓合理性
- 🎶 **韻律評分 (Prosody)**: 語調變化、重音位置、節奏感
- 🎯 **準確度評分 (Accuracy)**: 整體表達精準度
- 分數標準
- 96~100完美
- 81~95優秀
- 71~80尚可
- 0~70不合格
- 過關獎勵:
- 96~100+3 鑽石 + 30 XP
- 81~95+2 鑽石 + 20 XP
- 71~80+1 鑽石 + 10 XP
- 0~70時光卷
- **詳細評分顯示範例**:
```
📊 Speaking Score
【Sentence】91.9分:
Please make sure you have all the necessary documents before submitting your application.
🗣️ Pronunciation91.9
📈 Fluency97
🎶 Prosody83.3
✅ Completeness100
🎯 Accuracy96
【單字需要加強❌】
application ⭐⭐:
🟡 n
【建議改善】
• 🔚 尾音收口明確(-m, -n, -l, -k, -t避免吞音。
🎯 針對音素練習n
🤧 鼻音m/n/ŋ):軟顎下放,確保鼻腔共鳴與口型到位。
```
- [ ] **流暢度評分**: 評估表達的自然度和流暢性
- 達到優秀標準時觸發「表達流利」成就
- 獎勵: +10 鑽石 + 10 閃電能量
#### 技術實現方案
- [ ] **AI 模型選擇**: 待決定 (GPT-4/Claude/自建模型)
- [ ] **評分閾值設定**: 定義「優秀」標準的具體分數
- [ ] **即時分析**: 目標響應時間 < 2秒
- [ ] **成就觸發機制**: 於遊戲結束時,自動檢測並發放對應獎勵
- [ ] **成就觸發機制**: 自動檢測並發放對應獎勵
## AI 對話分析功能
@ -70,16 +36,13 @@
- [ ] **每句話即時分析**: 用戶說出的每句話都進行即時判斷
- 語法正確性分析(即時顯示於對話功能欄)
- 口說評分(即時顯示於對話功能欄)
- 任務完成狀態檢測
- 指定詞彙使用檢測
- [ ] **每句話點擊後分析**:用戶說出的每句話,點擊後才觸發
- 語用建議(當用戶點擊對話查看語用建議,則生成當前對話相對於整體對話的語用建議)
- [ ] **即時成功通知**: 當用戶提及詞彙或完成任務時立即回饋
- [ ] **三維度結算評分**: 對話結束後的綜合評分
- 語法錯誤率:整個劇本對話語法錯誤清單
- 語用建議:整個劇本對話的語用建議
- 口說整體的平均分數
- 對話語意合適分數滿分100>60為合格
- 語法錯誤率(必須=0所有句子都正確或訂正後正確
- 表達流暢平均分數滿分100>60為合格
## 情境對話核心系統
@ -128,7 +91,7 @@
- [ ] **回應思緒引導**: 分析用戶聽到這句話的反應及可能的回覆方向
- [ ] **回覆範例生成**: 生成一句具體的回覆範例
#### 免費輔助功能
#### 免費輔助工具
- [ ] **劇情任務範例**:
- 點擊任務提示按鈕後顯示一句範例
- 說明「這樣說可以完成任務」
@ -165,7 +128,7 @@
- [ ] **詞彙選擇題**:
- 根據示意圖選出正確的英文詞彙
- 4選1的單選題形式
- 答錯時,將題目並在最後重新測試
- 答錯會扣除命條(-1)並在最後重新測試
- [ ] **通關機制**: 所有詞彙題目都答對才算通關,直接獲得三顆星
#### 詞彙熟悉關卡
@ -179,26 +142,6 @@
- 提升詞彙識別和記憶連結
- [ ] **通關機制**: 所有配對和重組正確才算通關,直接獲得三顆星
#### 詞彙口說關卡
- 這關如果要玩是要消耗5鑽石
- 一一秀出詞彙例句,用戶要念出例句,系統會進行口說評分
-
- [ ] **口說評分**: 評估口說表達的自然度和流暢性
- 評分標準:整體評分 = 發音評分 & 完整度評分 & 流暢度評分
- 發音評分
- 完整度評分
- 流暢度評分
- 分數標準
- 96~100完美
- 81~95優秀
- 71~80尚可
- 0~70不合格
- 過關獎勵:
- 96~100+3 鑽石 + 30 XP
- 81~95+2 鑽石 + 20 XP
- 71~80+1 鑽石 + 10 XP
- 0~70時光卷
#### 詞彙內容設計標準
基於劇本的5詞彙組合設計
- [ ] **詞彙組合**: 每個劇本包含5個詞彙
@ -243,6 +186,38 @@
- 更新詞彙複習次數和下次複習時間
- 強化學習動機和持續性
## 限時對話系統 *(新增功能)*
### 300秒倒數計時機制
基於最新規格的時間管理系統:
#### 時間控制引擎
- [ ] **精準計時系統**:
- 300秒5分鐘的精確倒數計時
- 支援暫停和恢復功能(特殊情況下)
- 時間剩餘的視覺化顯示
- [ ] **時間壓力分析**:
- 分析時間壓力對用戶表現的影響
- 記錄不同時間段的對話品質變化
- 優化時間分配的學習建議
- [ ] **結算觸發機制**:
- 時間歸零自動觸發結算
- 支援用戶主動點擊「結算表現」
- 確保數據完整保存和分析
#### 時間效率優化
- [ ] **進度加權評分**:
- 基於完成時間給予額外評分
- 鼓勵高效但準確的對話完成
- 平衡速度與品質的評分機制
- [ ] **時間管理指導**:
- 提供時間分配的策略建議
- 分析用戶的時間使用模式
- 協助提升對話效率
## 關卡結算與訂正系統 *(新增基於最新規格)*
### 關卡表現結算
@ -255,121 +230,16 @@
#### 表現評分系統
- [ ] **評分標準** (每合格一項獲得一顆星):
1. **語法錯誤率 = 0**
1. **對話語意合適分數 > 60** (滿分100)
- 根據上下文一致性進行評分
- 語境適應性評估
- 意圖匹配度分析
2. **語法錯誤率 = 0**
- 用戶說的每句話經過語法判定都正確
- 或者訂正後都正確亦可
2. **表達流暢平均分數 > 60** (滿分100)
3. **表達流暢平均分數 > 60** (滿分100)
- 用戶說的每句話都會有流暢度分數
- 所有分數平均即為表達流暢平均分數
3. **對話語用評估分數 只建議不評分**
- 語用標準定義與用途:
1. **日常寒暄 (Small Talk)**
- 定義:不以傳遞實質資訊為主要目的,而是用於建立或維持社交關係的言談
- 用途:
- 建立對話氛圍與人際連結
- 作為開場、轉場或緩和氣氛的策略
- 在客服或 AI 助手中,用來提升「人性化」感
2. **間接表達 (Indirectness)**
- 定義:透過暗示、委婉語或迂迴方式表達意圖,避免直接衝突
- 用途:
- 避免冒犯,維持人際和諧
- 展現禮貌或文化上的尊重
- 在跨文化溝通中,辨識不同社會的表達習慣
3. **填充語 (Fillers)**
- 定義:非必要詞語,用來填補語流空隙、保持連續或爭取思考時間
- 用途:
- 提示對方「話還沒說完」
- 增加口語自然度(模擬真實對話)
- 作為語音辨識或對話系統的特徵,用於偵測自然口語
4. **同理回應 (Backchanneling)**
- 定義:聽話者以簡短語言或聲音表達注意、理解或支持,不打斷主要話輪
- 用途:
- 提供傾聽與理解的訊號,維持互動流暢
- 增強情感支持與共鳴
- 在對話系統中,提升使用者「被理解」的感受
5. **模糊語 (Hedging)**
- 定義:降低語氣確定性,避免過度斷言的語言策略
- 用途:
- 顯示謹慎,降低爭議風險
- 表達禮貌,避免武斷或冒犯
- 在學術或專業語境中,用來維持客觀或彈性
6. **文化慣用語 (Idioms)**
- 定義:文化群體中固定的表達方式,通常無法逐字翻譯
- 用途:
- 增加語言的自然性與文化深度
- 強化群體認同感
- 作為語言學習與跨文化理解的重要素材
- **語用評分範例**
```
--- Dialogue
👨(Client)How is Mr. Davies feeling about our upcoming meeting?
👨(Business Professional)Wow, you know what he is very anxious to meet you
The client is responding to the business professional's statement about Mr. Davies being anxious to meet him. He acknowledges the information and expresses his own anticipation for the meeting, maintaining a professional and engaged tone....
👨(Client)Anxious, you say? That's good to hear. I'm looking forward to it as well.
--- Overall Comment
【分數】:✅ 通過
【評論】:客戶在本次對話中展現了良好的專業溝通能力。他能夠清晰、直接地回應對方的信息,並有效使用附和語來維持對話的參與感。
【建議】:建議客戶在未來的對話中,根據情境需要,可以考慮適度運用間接表達和模糊語,以增加語氣的彈性與委婉度,尤其是在面對敏感話題或需要更細緻溝通的場合。持續保持積極的傾聽和回應,將有助於建立更良好的互動關係。
--- Intent Achievement Evaluation
🌍 Scenario
A professional setting where a person is informing another about a third party's eagerness to meet them, possibly before an introduction or a significant meeting. The man in the suit suggests a formal context.
🎯 Intent
Inform about someone's eagerness to meet.
🎯 是否實踐意圖:
【分數】Yes
【評論】:客戶成功地表達了對對方所提供信息的理解,並明確傳達了他對即將到來的會議的期待。
【建議】:意圖已完全達成,表現良好,無需改進。
--- Pragmatic Evaluation
😊 日常寒暄 (Small Talk)
【分數】4分
【評論】:客戶的應答簡潔專業,直接表達了對會面的期待,符合商務情境。
【建議】:在此情境下,客戶的表現良好,無需特別改進。如果想在開場時增加一點點寒暄,可以簡短地說 "It's a pleasure to finally connect, I hear great things."
🌀 間接表達 (Indirectness)
【分數】0分
【評論】客戶的回應直接且清晰沒有使用間接表達。在此情境下直接性是可接受的因此此項得分0分並非表示表現不佳而是因為間接表達在此情境中並非必要且客戶未採用。
【建議】:如果想讓語氣更委婉或在某些情況下顯得更為謹慎,可以使用間接句型,例如 "I appreciate you sharing that. It sounds like he's quite keen, and I share that sentiment." 或 "It's certainly encouraging to hear his eagerness; I'm equally enthusiastic."
🤔 填充語 (Fillers)
【分數】0分
【評論】對話中沒有使用語氣詞。在此情境下不使用語氣詞保持了專業和清晰度。此項得分0分因其在本次對話中屬於非必要項目且未被使用。
【建議】:保持清晰流暢的表達是良好的習慣。若未來在需要思考或組織語言時,可適度使用一些無害的填充詞如 "Well," 或 "You know," 來避免冷場,但應避免過度使用。例如:"Well, that's good to hear. I'm looking forward to it as well."
🙆 同理回應 (Backchanneling)
【分數】5分
【評論】:客戶有效地使用了附和語 "Anxious, you say?",這表明他專注於對話並確認了對方提供的信息,有助於維持對話的流暢性與參與感。
【建議】:繼續保持這種積極的傾聽和回應。在其他情況下,也可以使用 "I see what you mean," "Right," 或 "Exactly," 等來表示理解和附和。
🤷 模糊語 (Hedging)
【分數】2分
【評論】:客戶的表達比較直接,沒有使用模糊語來軟化語氣或預留彈性。在這種專業且直接的語境下,雖然直接表達沒問題,但適度的模糊語可以使語氣更溫和或為未來討論留下空間。
【建議】在某些情境下尤其是在討論尚未確定的事項或表達個人看法時適當使用模糊語hedging可以讓語氣更委婉避免過於武斷。例如可以說 "I suppose that's good to hear" 或 "I'm certainly looking forward to it as well, assuming everything goes according to plan."
🐉 文化慣用語 (Idioms)
【分數】0分
【評論】對話中沒有使用慣用語。在正式的商務場合避免使用過多的慣用語有助於保持溝通的清晰度尤其是在面對不同文化背景的對話者時。此項得分0分因其在本次對話中屬於非必要項目且未被使用。
【建議】:在正式場合,直接清晰的表達比慣用語更受青睞。如果希望增加語言的豐富性,可以在非正式場合適度使用,但務必確保對方能理解。
```
### 結算流程系統
#### 過關流程

View File

@ -0,0 +1,300 @@
# 商業邏輯與營收規則
## 概述
基於實際 UI 設計,定義 Drama Ling 的遊戲化商業模式,以鑽石貨幣系統為主的道具購買機制,搭配簡潔的訂閱服務。
## 鑽石貨幣系統 (主要營收機制)
### 鑽石獲得方式
- [ ] **初始贈送**: 新用戶註冊贈送1500鑽石
- [ ] **每日登入**: 登入獎勵鑽石
- [ ] **學習成就**: 完成關卡獲得鑽石獎勵
- [ ] **現金購買**: 直接購買鑽石包
- [ ] **廣告獎勵**: 觀看廣告獲得少量鑽石
### 道具商店系統
#### 加時道具 🕰️
**功能**: 為對話訓練加時1分3秒
- [ ] **單個購買**: 1個 = 300鑽石
- [ ] **組合包**: 5個 = 1,200鑽石 (節省20%)
- [ ] **使用情境**: 挑戰關卡時間不夠時使用
- [ ] **效果**: 獲得更長的思考和組織時間
#### 補命道具 ❤️
**功能**: 為對話學習的時間卡復活1次機會
- [ ] **單個購買**: 1個 = 100鑽石
- [ ] **組合包**: 5個 = 400鑽石 (節省20%)
- [ ] **使用情境**: 對話練習失敗時使用
- [ ] **效果**: 可重新挑戰失敗的關卡
#### 時光卷 ✨ *(更新基於最新規格)*
**功能**: 可挑戰1次前階段關卡或獲得失敗安慰獎勵
- [ ] **獲得方式**:
- 詞彙認識關卡失敗獲得1張
- 詞彙熟悉關卡失敗獲得1張
- 對話訓練失敗獲得1張
- 複習詞彙失敗安慰獎勵
- [ ] **使用情境**:
- 挑戰時光關卡(前階段未玩過的對話訓練)
- 若前階段都已完成則隨機挑選關卡
- [ ] **消費機制**: 點擊「我要挑戰」消耗1張時光卷
- [ ] **特殊效果**: 成功通關的詞彙一樣加入詞彙複習清單
#### 回覆提示道具 💡 *(更新基於最新規格)*
**功能**: 當用戶在扮演角色遇到卡關,不知道該講什麼或怎麼講時提供協助
- [ ] **單個購買**: 1個 = 30鑽石
- [ ] **組合包**: 10個 = 250鑽石 (節省17%)
- [ ] **觸發條件**: 用戶主動請求回覆協助時使用
- [ ] **效果**: 根據對話室中最後一句話生成三層引導內容
**回覆引導內容** (消耗道具):
- [ ] **對方意圖分析**: 分析對方說這句話的意圖
- [ ] **回應思緒引導**: 分析用戶聽到這句話的反應及可能的回覆方向
- [ ] **回覆範例生成**: 生成一句具體的回覆範例
**免費輔助功能** (不消耗道具):
- [ ] **劇情任務範例**: 點擊任務提示按鈕顯示「這樣說可以完成任務」的範例
- [ ] **指定詞彙範例**: 展示指定詞彙的正確使用方式
- [ ] **中翻英翻譯**: 直接將使用者的中文以Google翻譯轉譯成英文
**使用規則**:
- [ ] **任務完成狀態**: 當任務已經完成時,不會顯示任務提示按鈕
- [ ] **使用限制**: 每次對話合理使用,避免過度依賴
- [ ] **學習導向**: 鼓勵用戶從輔助逐步過渡到獨立表達
## 關卡命條系統 *(新增核心機制)*
### 命條管理機制
基於最新規格的闖關生命值系統:
#### 命條基本規則
- [ ] **初始設定**: 新用戶預設5個命條上限為5
- [ ] **闖關門檻**: 開始闖關前檢查命條是否大於1
- [ ] **自動回復**: 每5小時自動獲得1個命條
- [ ] **命條歸零**: 當命條扣完歸零時即闖關失敗
#### 命條消耗規則
- [ ] **詞彙認識關卡**: 答錯題目扣除1個命條
- [ ] **詞彙熟悉關卡**: 答錯題目扣除1個命條
- [ ] **對話訓練關卡**: 通關失敗扣除1個命條
- [ ] **重複答題**: 答錯的題目需在最後重新回答,再次答錯繼續扣命條
#### 命條不足處理
- [ ] **闖關阻擋**: 命條不足時無法開始新的關卡挑戰
- [ ] **購買機制**: 命條不足時可使用鑽石購買命條
- 1個命條 = 100鑽石
- 5個命條組合包 = 400鑽石節省20%
- [ ] **等待恢復**: 用戶可選擇等待5小時自然恢復命條
- [ ] **視覺提示**: 金錢不夠時購買按鈕顯示為disable狀態
### 關卡結構系統 *(新增基於最新規格)*
基於13階段的完整學習路徑
#### 階段化學習架構
- [ ] **學習階層**: 第x階段 > 第x劇本 > 某某關卡
- [ ] **總體規劃**: 共13個學習階段
- [ ] **劇本數量**: 每階段包含20個以上劇本持續增加
- [ ] **關卡類型**: 每個劇本固定包含三種關卡
- 詞彙認識關卡
- 詞彙熟悉關卡
- 對話訓練關卡
#### 關卡解鎖機制
- [ ] **順序闖關**: 必須按照關卡順序進行,不可跳關
- [ ] **解鎖條件**: 完成前一關卡才能解鎖下一關
- [ ] **通關標準**: 即使獲得零顆星,成功通關仍會解鎖下一關
- [ ] **星級獎勵**: 詞彙認識和詞彙熟悉關卡通關直接給予三顆星
#### 連續學習獎勵
- [ ] **連續學習天數**: 追蹤用戶連續學習的天數
- [ ] **每日學習判定**: 當日完成至少一個關卡即計為學習一天
- [ ] **連續獎勵機制**: 基於連續天數給予額外獎勵
- 7天連續: 額外經驗值獎勵
- 14天連續: 免費命條補充
- 30天連續: 特殊成就徽章
## 情境對話核心商業機制 *(新增功能)*
### 雙重通關條件獎勵系統
基於最新規格的結構化通關獎勵機制:
#### 劇情任務完成獎勵
- [ ] **基礎完成獎勵**: 完成劇情任務獲得 +10 鑽石 + 10 閃電能量
- [ ] **任務品質加成**: 高品質完成劇情任務額外 +5 鑽石
- [ ] **即時獎勵機制**: 任務完成立即觸發獎勵通知和發放
- [ ] **進度追蹤獎勵**: 連續完成劇情任務的連擊獎勵機制
#### 指定詞彙使用獎勵
- [ ] **詞彙掌握獎勵**: 正確使用指定詞彙獲得 +5 鑽石 + 5 閃電能量
- [ ] **自然使用加成**: 詞彙使用自然且符合語境額外 +3 鑽石
- [ ] **即時反饋獎勵**: 使用詞彙時立即觸發成功通知
- [ ] **詞彙精通獎勵**: 單次對話使用多個指定詞彙的組合獎勵
#### 結算獎勵系統 *(更新基於最新規格)*
**過關獎勵** (同時滿足劇情任務和詞彙要求):
- [ ] **基礎通關獎勵**: 獲得金幣和經驗值
- [ ] **星級獎勵系統**: 基於三維度評分獲得1-3顆星
- 語意合適分數 > 60 (滿分100) = 1顆星
- 語法錯誤率 = 0 (所有句子正確或訂正後正確) = 1顆星
- 表達流暢平均分數 > 60 (滿分100) = 1顆星
- [ ] **訂正後獎勵**: 選擇立即訂正後獲得訂正後的獎勵數值
**失敗安慰獎勵**:
- [ ] **安慰獎**: 獲得時光卷一張(可重新挑戰關卡)
- [ ] **鼓勵機制**: 提供重新挑戰的動機和資源
### 300秒限時挑戰機制
基於最新規格的時間管理商業系統:
#### 限時挑戰入場機制
- [ ] **挑戰門票**: 參與300秒限時挑戰需消耗 50鑽石 入場費
- [ ] **免費次數**: 每日首次限時挑戰免費,後續挑戰需付費
- [ ] **VIP特權**: 付費用戶每日3次免費限時挑戰機會
- [ ] **好友邀請**: 邀請好友一同挑戰可減免入場費用
#### 時間相關道具系統
- [ ] **時間暫停道具**: 暫停倒數計時30秒消耗 100鑽石
- [ ] **時間加成道具**: 增加額外60秒挑戰時間消耗 150鑽石
- [ ] **快速完成獎勵**: 在180秒內完成獲得 +15 鑽石時間獎勵
- [ ] **壓力測試獎勵**: 在最後30秒完成挑戰獲得 +25 鑽石壓力獎勵
#### 限時結算獎勵機制
- [ ] **基礎完成獎勵**: 300秒內完成對話獲得 +30 鑽石 + 30 閃電能量
- [ ] **時間效率獎勵**: 基於完成時間給予 1.2x - 2.0x 獎勵倍數
- [ ] **雙重條件加成**: 限時環境下達成雙重條件額外 +50 鑽石
- [ ] **排行榜獎勵**: 每週限時挑戰排行榜前10名特殊獎勵
## 簡化訂閱系統 (次要營收)
### 訂閱服務設計
- [ ] **7天免費體驗**: 新用戶可免費使用7天完整功能
- [ ] **目標**: 與靈兔一起闖關學英文,玩出一口流利的口說英文
- [ ] **成功頁面**: 可愛外星人角色設計增加親切感
- [ ] **續約提醒**: "還在等什麼先來7天免費體驗看看"
### 資源不足機制
- [ ] **提醒彈窗**: "任務提示需要消耗30資石但你目前資石不足"
- [ ] **引導購買**: 直接引導用戶到道具商店
- [ ] **清楚的需求說明**: 顯示具體需要的資源數量
- [ ] **一鍵解決**: 提供"了解"按鈕引導至購買頁面
## 道具購買流程設計
### 購買確認機制
#### 加時道具購買確認
- [ ] **視覺化設計**: 大型時鐘圖示加上加號圖示
- [ ] **清楚的價值說明**: "用加時道具去玩出更高的分數吧!"
- [ ] **遊戲化設計**: 對話式的遊戲要求,而非單純交易
- [ ] **即時購買**: "立即購買 300鑽石"按鈕
- [ ] **取消選項**: 簡單的"不,謝謝"選項
#### 補命道具購買確認
- [ ] **心形圖示**: 愛心加號的視覺設計
- [ ] **功能說明**: 明確告知為學習時間復活用途
- [ ] **價格透明**: 直接顯示100鑽石的明確價格
- [ ] **低價格策略**: 相對低廉的價格降低購買的障礙感
#### 回覆提示道具購買確認
- [ ] **燈泡圖示**: 智慧提示的視覺設計
- [ ] **功能說明**: "獲得AI智慧引導突破對話卡關"
- [ ] **價格透明**: 直接顯示30鑽石的低門檻價格
- [ ] **價值展示**: 強調包含四合一功能(意圖分析+思維引導+回覆範例+翻譯)
- [ ] **即時解決**: "立即獲得對話靈感"的行動導向按鈕
- [ ] **低價策略**: 最低價道具降低首次付費心理障礙
### 購買成功機制
- [ ] **即時生效**: 購買後立即可在遊戲中使用
- [ ] **清楚的庫存顯示**: 在主界面右上角顯示目前鑽石數量(1500)
- [ ] **使用指引**: 在需要使用道具時提供明確的使用方式
## 訂閱成功體驗設計
### 成功頁面設計理念
- [ ] **可愛風格**: 使用外星人角色創造親切感
- [ ] **清楚的價值主張**: "和靈兔一起闖關學英文!玩出一口流利的口說英文!"
- [ ] **緊迫性設計**: "還在等什麼先來7天免費體驗看看"
- [ ] **行動導向**: 大型明顯的CTA按鈕"領取7天體驗資格"
### 用戶心理設計
- [ ] **降低抵觸**: 免費體驗降低初次購買的心理障礙
- [ ] **社交證明**: 使用外星人形象增加記憶點
- [ ] **成就感**: 體驗成功的成就感與滿意度
- [ ] **持續動機**: 透過可愛設計建立情感連結
## 鑽石購買套餐設計
### 推薦套餐結構
- [ ] **新手包**: 500鑽石 = NT$30 (首次購買優惠)
- [ ] **基礎包**: 1,200鑽石 = NT$60
- [ ] **價值包**: 2,500鑽石 = NT$99 (最受歡迎)
- [ ] **豪華包**: 5,000鑽石 = NT$190
- [ ] **至尊包**: 12,000鑽石 = NT$390
### 定價策略考量
- [ ] **低門檻**: NT$30的新手包降低進入的障礙
- [ ] **價值感**: 每1鑽石約NT$0.04的合理價格
- [ ] **組合購買優惠**: 5個裝比單購節省20-25%
- [ ] **心理定位**: 道具價格設定在100-1200鑽石區間
## 付費轉換優化
### 轉換漏斗設計
- [ ] **無縫體驗**: 從免費使用到需要購買的自然過渡
- [ ] **第一次付費**: 通常為最低價的道具(回覆提示道具30鑽石)
- [ ] **漸進式需求**:
- **入門級**: 回覆提示道具(30鑽石) - 解決即時卡關問題
- **成長級**: 限時挑戰門票(50鑽石) - 體驗競技式學習
- **進階級**: 補命道具(100鑽石) - 提供重新挑戰機會
- **專家級**: 時間相關道具(100-150鑽石) - 優化限時挑戰表現
- **大師級**: 加時道具(300鑽石) - 獲得更充裕練習時間
- [ ] **成就動機**: 通過道具使用獲得更好成績和雙重通關的成就感
- [ ] **學習進步感**: 回覆提示功能和即時獎勵讓用戶感受到明顯的學習支援
- [ ] **競技驅動**: 300秒限時挑戰創造緊張感和競爭動機
- [ ] **社交壓力**: 好友排行榜和限時挑戰排名驅動持續消費
## 簡化廣告系統
### 廣告展示策略
- [ ] **非強制性**: 主要用於獲得額外鑽石獎勵
- [ ] **獎勵導向**: 觀看廣告獲得25-50鑽石
- [ ] **頻率控制**: 避免影響核心遊戲體驗
- [ ] **品質篩選**: 優先顯示教育和遊戲相關廣告
## 技術實現考量
### 支付系統整合
- [ ] **第三方支付串接**: 整合多種支付方式API
- [ ] **交易安全**: PCI DSS合規的支付安全機制
- [ ] **即時到帳**: 購買後立即可使用的鑽石發放
- [ ] **退款處理**: 簡化的退款處理流程
### 道具系統管理
- [ ] **即時庫存**: 即時更新用戶鑽石和道具庫存
- [ ] **使用追蹤**: 追蹤道具使用情況和效果
- [ ] **防作弊機制**: 防止道具被不正當獲得或使用
- [ ] **數據分析**: 道具使用率和購買轉換率分析
---
## 關鍵差異與實際設計對齊
### 與原規劃的主要不同
1. **簡化商業模式**: 從複雜的4層訂閱制改為鑽石道具+簡單訂閱
2. **遊戲化貨幣**: 使用鑽石取代直接台幣定價
3. **低門檻策略**: 最低100鑽石的道具降低付費門檻
4. **視覺化購買**: 可愛的確認彈窗而非複雜的訂閱頁面
5. **即時獎勵**: 購買後立即可用,增加滿足感
### 實際應用案例
- **道具商店頁面**: 清楚的分類和價格顯示
- **購買確認彈窗**: 遊戲化的對話式確認
- **資源不足提醒**: 直接引導到解決方案
- **訂閱成功頁**: 可愛外星人增加品牌親和力
---
**最後更新**: 2024年9月5日
**基於實際設計**: 05_views 目錄中的商業相關UI設計
**審查週期**: 與實際UI設計保持同步更新

Some files were not shown because too many files have changed in this diff Show More