Compare commits
9 Commits
917f45ec91
...
644b6f2b15
| Author | SHA1 | Date |
|---|---|---|
|
|
644b6f2b15 | |
|
|
ad22c7f4fd | |
|
|
f55007d2d8 | |
|
|
8bddc3b06d | |
|
|
8346c96908 | |
|
|
32e8c8c741 | |
|
|
32cc10ffd5 | |
|
|
f8b47bdf5a | |
|
|
adc7389916 |
|
|
@ -100,7 +100,19 @@
|
|||
"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(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:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
|
|
|||
|
|
@ -0,0 +1,389 @@
|
|||
# 🚀 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畫面
|
||||
19
TASKS.md
|
|
@ -3,27 +3,8 @@
|
|||
## 📋 當前任務
|
||||
|
||||
### 🔥 緊急任務
|
||||
- [ ] 🔄 **前端架構重構: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小時) ✅
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
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'
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"trailingComma": "none",
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": false,
|
||||
"arrowParens": "avoid",
|
||||
"endOfLine": "lf",
|
||||
"vueIndentScriptAndStyle": false,
|
||||
"htmlWhitespaceSensitivity": "ignore"
|
||||
}
|
||||
|
|
@ -1,209 +0,0 @@
|
|||
/* 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']>
|
||||
}
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
/* 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']
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
|
||||
|
|
@ -1,103 +1,17 @@
|
|||
<!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 - 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">
|
||||
<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">
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
<div id="app">
|
||||
<div class="loading-spinner">
|
||||
載入中...
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="./src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,68 +1,22 @@
|
|||
{
|
||||
"name": "dramaling-web",
|
||||
"name": "web-native",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"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"
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"devDependencies": {
|
||||
"@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"
|
||||
"@vitejs/plugin-legacy": "^7.2.1",
|
||||
"sass": "^1.92.1",
|
||||
"terser": "^5.44.0",
|
||||
"vite": "^7.1.5"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,68 +0,0 @@
|
|||
<!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>
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
<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>
|
||||
|
Before Width: | Height: | Size: 572 B |
|
|
@ -1,136 +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>
|
||||
/* 完全自定義樣式 - 沒有框架干擾 */
|
||||
* { 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>
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
<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>
|
||||
|
Before Width: | Height: | Size: 598 B |
|
|
@ -1,14 +0,0 @@
|
|||
<!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>
|
||||
|
|
@ -1,360 +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>
|
||||
/* 重置樣式 */
|
||||
* {
|
||||
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>
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,328 +0,0 @@
|
|||
// 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;
|
||||
}
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
// 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
|
||||
|
|
@ -1,173 +0,0 @@
|
|||
// 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; }
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
// 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 '';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,417 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,155 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,217 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,292 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,515 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,223 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,197 +0,0 @@
|
|||
<!-- 通用圖示組件 -->
|
||||
<!-- 支援常用圖示,基於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>
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,219 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,270 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,280 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,194 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,413 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,551 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,333 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
// 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();
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
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!')
|
||||
|
|
@ -0,0 +1,585 @@
|
|||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,322 @@
|
|||
// 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];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,210 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,277 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,325 +0,0 @@
|
|||
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']
|
||||
}
|
||||
})
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
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'
|
||||
|
|
@ -1,364 +0,0 @@
|
|||
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
|
||||
}
|
||||
})
|
||||
|
|
@ -1,422 +0,0 @@
|
|||
// 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
|
||||
}
|
||||
})
|
||||
|
|
@ -1,349 +0,0 @@
|
|||
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
|
||||
}
|
||||
})
|
||||
|
|
@ -1,280 +0,0 @@
|
|||
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']
|
||||
}
|
||||
})
|
||||
|
|
@ -1,308 +0,0 @@
|
|||
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']
|
||||
}
|
||||
})
|
||||
|
|
@ -1,393 +0,0 @@
|
|||
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']
|
||||
}
|
||||
})
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
@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);
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
// 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,577 @@
|
|||
// 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,198 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,181 +0,0 @@
|
|||
// 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
|
||||
}
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
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 // 排名變化
|
||||
}
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
// 詞彙相關的型別定義
|
||||
|
||||
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
|
||||
}[]
|
||||
}
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
// 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,314 +0,0 @@
|
|||
// 工具函數集合
|
||||
|
||||
/**
|
||||
* 生成唯一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
|
||||
}
|
||||
|
|
@ -1,421 +0,0 @@
|
|||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -1,457 +0,0 @@
|
|||
/**
|
||||
* 智能間隔複習演算法
|
||||
* 基於 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: []
|
||||
}
|
||||
}
|
||||
|
|
@ -1,581 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,544 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,327 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,354 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,462 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,262 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,837 +0,0 @@
|
|||
<!-- 詞彙選擇題結果頁面 (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) {
|
||||
// 模擬結果計算(實際應該從Store或API獲取)
|
||||
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>
|
||||
|
|
@ -1,829 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,744 +0,0 @@
|
|||
<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: '/ɪˈmɜː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>
|
||||
|
|
@ -1,354 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,992 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,168 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,242 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
<!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>
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -1,190 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -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)
|
||||
**用途**: 涵蓋使用者體驗、視覺設計和互動模式的文檔。**專注於知識管理和規格定義**。
|
||||
|
||||
| 檔案名稱 | 用途 |
|
||||
|------|---------|
|
||||
| `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視圖設計檔案 |
|
||||
| 檔案名稱 | 用途 |
|
||||
| ---------------------------- | --------------------------------------------------- |
|
||||
| `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設計系統樣式表
|
||||
|
||||
**目標讀者**: 設計師、前端開發人員和產品經理。
|
||||
|
||||
|
|
@ -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/html-prototypes/`
|
||||
- 原型參考: `/02_design/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.0 - 整合文檔層規範,明確定義文檔職責和禁止內容 (2025-09-10)
|
||||
**版本**: 3.0.1 - 重新定義06_development目錄職責,明確開發過程管理範疇 (2025-09-10)
|
||||
|
|
@ -24,6 +24,34 @@
|
|||
- 用戶可透過客服申請帳戶刪除後重新註冊
|
||||
```
|
||||
|
||||
|
||||
#### 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
|
||||
規則名稱: 密碼複雜度要求
|
||||
|
|
@ -111,9 +139,8 @@
|
|||
- 用戶初始生命值為5條
|
||||
- 答錯或失敗會消耗1條生命
|
||||
- 生命值為0時無法進行新的學習活動
|
||||
- 每6小時自動回復1條生命,最多回復到5條
|
||||
- 每4小時自動回復1條生命,最多回復到5條
|
||||
生命恢復:
|
||||
- 付費用戶生命回復速度提升至4小時1條
|
||||
- 可使用鑽石立即購買生命(50鑽石=1條生命)
|
||||
- 完成每日任務獎勵1條生命
|
||||
- 觀看廣告可獲得1條生命(每日最多3次)
|
||||
|
|
@ -158,14 +185,12 @@
|
|||
規則名稱: 詞彙掌握度評估
|
||||
適用範圍: 所有詞彙學習活動
|
||||
規則內容:
|
||||
- 新詞彙初始掌握度為0%
|
||||
- 正確使用一次增加20%掌握度
|
||||
- 錯誤使用一次減少10%掌握度
|
||||
- 掌握度80%以上視為已掌握
|
||||
- 起始點: 新詞彙 0% 掌握度
|
||||
- 成功獎勵: +20% 掌握度
|
||||
- 錯誤懲罰: -5% 掌握度
|
||||
- 掌握標準: 80% 以上視為已掌握
|
||||
複習機制:
|
||||
- 掌握度<50%: 24小時後複習
|
||||
- 掌握度50-79%: 3天後複習
|
||||
- 掌握度80%+: 7天後複習
|
||||
- 複習時間 = 最近複習日期 + (2^成功次數) 天
|
||||
- 連續3次正確可延長複習間隔
|
||||
```
|
||||
|
||||
|
|
@ -201,38 +226,18 @@
|
|||
- 專精(C2): 學術表達與文化語境
|
||||
```
|
||||
|
||||
### ⏰ 時間與限制
|
||||
|
||||
#### BR-TIME-01: 限時挑戰規則
|
||||
```yaml
|
||||
規則名稱: 300秒挑戰機制
|
||||
適用範圍: 限時挑戰模式
|
||||
規則內容:
|
||||
- 每次挑戰固定300秒(5分鐘)
|
||||
- 需要消耗1張挑戰門票
|
||||
- 時間結束立即停止,不可延長
|
||||
- 成績根據正確率和剩餘時間計算
|
||||
門票機制:
|
||||
- 免費用戶每日獲得2張門票
|
||||
- 付費用戶每日獲得5張門票
|
||||
- 可用鑽石購買額外門票(100鑽石/張)
|
||||
- 門票不累積,當日未用完隔日重置
|
||||
```
|
||||
|
||||
#### BR-TIME-02: 學習會話時限
|
||||
```yaml
|
||||
規則名稱: 學習會話超時處理
|
||||
適用範圍: 所有學習活動會話
|
||||
規則內容:
|
||||
- 單次學習會話最長2小時
|
||||
- 30分鐘無操作自動暫停
|
||||
- 暫停狀態保持30分鐘後自動結束
|
||||
- 會話結束自動保存當前進度
|
||||
- 單次學習會話最長5分鐘
|
||||
- 5分鐘後自動結束
|
||||
- 會話結束會自動結算,並存到紀錄
|
||||
數據保存:
|
||||
- 已完成的練習立即保存
|
||||
- 進行中的練習保存狀態
|
||||
- 學習時間準確記錄
|
||||
- 經驗值和獎勵延遲結算
|
||||
- 經驗值和獎勵結算
|
||||
```
|
||||
|
||||
### 🤝 社群互動
|
||||
|
|
@ -258,15 +263,14 @@
|
|||
規則名稱: 競爭排名計算
|
||||
適用範圍: 所有排行榜功能
|
||||
規則內容:
|
||||
- 排行榜分為好友榜和全球榜
|
||||
- 排行榜分為好友榜
|
||||
- 每週一凌晨重置週排行榜
|
||||
- 每月1號重置月排行榜
|
||||
- 年度排行榜保持全年累積
|
||||
排名計算:
|
||||
- 主要依據: 學習時間 × 正確率 × 連續天數加成
|
||||
- 主要依據: 遊玩關卡所獲得的經驗值
|
||||
- 相同分數按學習開始時間排序
|
||||
- 作弊或異常數據將被排除
|
||||
- 排行榜前10名獲得特殊獎勵
|
||||
```
|
||||
|
||||
### 🛡️ 安全與隱私
|
||||
|
|
|
|||
|
|
@ -16,8 +16,9 @@
|
|||
- **進階者** - 語言程度C1-C2,精進專業溝通
|
||||
|
||||
#### 💰 付費用戶 (Premium User)
|
||||
- **試用用戶** - 7天免費體驗期間
|
||||
- **試用用戶** - 7天免費體驗訂閱期間
|
||||
- **訂閱用戶** - 月費/年費訂閱會員
|
||||
- **進階用戶** - 除了基礎功能,還有更多自訂學習功能可使用
|
||||
- **高價值用戶** - 大量購買鑽石和道具
|
||||
|
||||
#### 🎯 目標導向用戶
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |