Compare commits
No commits in common. "9345654cc17f782935d4e520fefaf839b89f2e21" and "d31340a05a464741c11d2f58b2787e75a5656242" have entirely different histories.
9345654cc1
...
d31340a05a
|
|
@ -72,26 +72,7 @@
|
|||
"Bash(do echo -n \"$ui: \")",
|
||||
"Bash(if grep -q \"$ui\" /tmp/system_ui_list.txt)",
|
||||
"Bash(fi)",
|
||||
"Bash(done)",
|
||||
"Bash(/Users/jettcheng1018/flutter/bin/flutter run -d 148D878C-62EB-4B60-9C04-2173EC0248BF)",
|
||||
"Bash(/Users/jettcheng1018/flutter/bin/flutter run -d Medium_Phone_API_36.0)",
|
||||
"Bash(/Users/jettcheng1018/flutter/bin/flutter emulators --launch Medium_Phone_API_36.0)",
|
||||
"Bash(dotnet run:*)",
|
||||
"Bash(dotnet --version)",
|
||||
"Bash(npm install)",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(npm run dev:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(/bashes)",
|
||||
"Bash(pkill:*)",
|
||||
"Bash(say:*)",
|
||||
"Bash(./dl list)",
|
||||
"Bash(./dl task)",
|
||||
"Bash(./scripts/file_version_manager.sh:*)",
|
||||
"Bash(./scripts/archive_file.sh:*)",
|
||||
"Bash(./scripts/view_archives.sh:*)",
|
||||
"Bash(tree:*)",
|
||||
"Bash(./sop/scripts/archive_file.sh:*)"
|
||||
"Bash(done)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
|
|
|||
|
|
@ -43,73 +43,13 @@
|
|||
|
||||
## 🎯 核心工作原則
|
||||
|
||||
### 1. 三層架構任務管理原則 (更新 2025-09-09)
|
||||
### 1. 工具優先原則
|
||||
- **必須優先使用現有工具和腳本**
|
||||
- 所有專案管理都透過 `./dl` 系統
|
||||
- 所有問題記錄都透過 `./dl issue` 創建
|
||||
- 所有報告都必須透過 `./dl report` 創建
|
||||
|
||||
#### 🏗️ 架構設計
|
||||
- **TASK_MANAGEMENT.md**: 簡潔任務列表,80%日常工作在此進行
|
||||
- **projects/[專案名].md**: 詳細專案規格和技術文檔
|
||||
- **projects/templates/**: 純模板參考,不追蹤狀態
|
||||
|
||||
#### 📋 任務記錄標準格式
|
||||
```markdown
|
||||
- [ ] 🎯 **任務名稱** - 簡短描述 (預估時間)
|
||||
- 📄 參考: [專案詳細文檔](projects/project-name.md)
|
||||
```
|
||||
|
||||
#### 🔄 工作流程
|
||||
1. **討論階段**: 與Claude自由討論需求
|
||||
2. **記錄階段**: Claude記錄簡潔任務到TASK_MANAGEMENT.md + 創建詳細專案文檔
|
||||
3. **執行階段**: 查看TASK_MANAGEMENT.md選擇批量執行
|
||||
4. **完成階段**: 只在TASK_MANAGEMENT.md標記完成✅
|
||||
|
||||
#### 📊 管理原則
|
||||
- **優先級驅動**: 🔥緊急→⚠️重要→📝一般→💡想法
|
||||
- **類型化管理**: FE/BE/AI/MB/DOC/ENV/TEST標記
|
||||
- **單一更新點**: 狀態只在TASK_MANAGEMENT.md更新
|
||||
- **參考連結**: 任務連結到對應專案詳細文檔
|
||||
|
||||
### 2. 文件版本管理SOP (新增 2025-09-09)
|
||||
|
||||
#### 🗃️ 強制性歸檔原則
|
||||
**重要**: Claude 在更新重要文件時,**必須**先歸檔舊版本,避免資料污染
|
||||
|
||||
#### 📋 適用情況
|
||||
- 重構現有系統文件時 (如TASK_MANAGEMENT.md)
|
||||
- 替換重要配置文件時
|
||||
- 大幅修改核心文檔時
|
||||
- 產生備份版本的文件時 (如*_OLD.md, *_NEW.md)
|
||||
|
||||
#### 🔧 SOP執行步驟
|
||||
```bash
|
||||
# 1. 發現需要歸檔的舊文件時,立即執行歸檔命令
|
||||
./scripts/archive_file.sh 文件路徑 "歸檔原因"
|
||||
|
||||
# 範例
|
||||
./scripts/archive_file.sh TASK_MANAGEMENT_OLD.md "任務管理系統重構為三層架構"
|
||||
```
|
||||
|
||||
#### 📊 歸檔機制
|
||||
- **目標目錄**: `archive/YYYY-MM-DD/`
|
||||
- **文件命名**: `HHMMSS_原文件名`
|
||||
- **日誌記錄**: `archive/logs/file_migration.log`
|
||||
- **自動化**: 時間戳記、操作者記錄、原因說明
|
||||
|
||||
#### 🚫 禁止行為
|
||||
- **絕對禁止**將舊版本文件留在根目錄或主要工作目錄
|
||||
- 不可手動移動文件 (必須使用腳本確保日誌記錄)
|
||||
- 不可跳過歸檔步驟直接刪除文件
|
||||
|
||||
#### 📋 檢查指令
|
||||
```bash
|
||||
# 查看歸檔狀態 (推薦)
|
||||
./scripts/view_archives.sh
|
||||
|
||||
# 或手動查看
|
||||
cat archive/logs/file_migration.log # 查看日誌
|
||||
ls -la archive/$(date '+%Y-%m-%d')/ # 列出今日歸檔
|
||||
```
|
||||
|
||||
### 3. 專案管理整合原則 (新增 2025-09-08)
|
||||
### 2. 專案管理整合原則 (新增 2025-09-08)
|
||||
- **階段化執行**: 大型項目拆分為可管理的階段
|
||||
- **任務分類**: 使用類型標記(FE/BE/AI/MB/DOC/ENV/TEST)
|
||||
- **進度追蹤**: 即時更新任務狀態 (⏳ → 🔄 → ✅)
|
||||
|
|
@ -118,22 +58,15 @@ ls -la archive/$(date '+%Y-%m-%d')/ # 列出今日歸檔
|
|||
### 3. 協作提醒原則 (整合 2025-09-08)
|
||||
用戶可以使用以下提醒語句確保問題被記錄:
|
||||
|
||||
**標準提醒語句:** (更新 2025-09-09)
|
||||
**標準提醒語句:**
|
||||
```
|
||||
"如果你在過程中發現任何規格不確定、衝突、技術問題或需要決策的地方,請記錄到任務管理系統。"
|
||||
"如果你在過程中發現任何規格不確定、衝突、技術問題或需要決策的地方,請使用問題管理系統記錄下來。"
|
||||
```
|
||||
|
||||
**簡短版本:**
|
||||
```
|
||||
"遇到問題就記錄到任務系統"
|
||||
"發現問題就用統一任務管理記錄"
|
||||
"討論的內容請記錄到 TASK_MANAGEMENT.md"
|
||||
```
|
||||
|
||||
**新增批量執行提醒:**
|
||||
```
|
||||
"這些任務我已經記錄到系統中,你可以選定時間批量執行"
|
||||
"任務已整理完成,建議按優先級批量處理"
|
||||
"遇到問題就記錄到問題系統"
|
||||
"發現問題就用 ./dl issue 記錄"
|
||||
```
|
||||
|
||||
**具體場景提醒:**
|
||||
|
|
@ -338,25 +271,19 @@ Claude: 識別為 MB 類型任務,更新狀態為進行中...
|
|||
✅ 任務完成,狀態已更新 (2025-09-08)
|
||||
```
|
||||
|
||||
### 範例2:統一任務管理 (更新 2025-09-09)
|
||||
### 範例2:發現問題並記錄
|
||||
```
|
||||
用戶: "我想加入社交功能讓用戶互相挑戰,遇到問題就記錄"
|
||||
Claude: 這個功能需要以下幾個部分...
|
||||
[討論功能細節]
|
||||
我已經將以下任務記錄到統一任務管理系統:
|
||||
- 🎨 FE 社交挑戰UI設計 (預估4-6小時)
|
||||
- ⚙️ BE 挑戰系統API開發 (預估8-12小時)
|
||||
- 📚 DOC 社交功能規格文檔 (預估2-3小時)
|
||||
你可以在 TASK_MANAGEMENT.md 查看詳細內容並安排執行時間
|
||||
用戶: "實作語音輸入功能,遇到問題就記錄"
|
||||
Claude: 我發現API規格中音頻格式支援不明確...
|
||||
[使用 ./dl issue 記錄問題]
|
||||
已記錄問題:音頻格式規格不明確 ⚠️
|
||||
```
|
||||
|
||||
### 範例3:問題發現和記錄 (更新 2025-09-09)
|
||||
### 範例3:文檔檢查
|
||||
```
|
||||
用戶: "檢查API文檔一致性"
|
||||
用戶: "檢查API文檔一致性,發現問題就用問題系統記錄"
|
||||
Claude: 我發現用戶管理API和認證API的錯誤碼定義衝突...
|
||||
已將此問題記錄到任務管理系統:
|
||||
- ⚠️ BE API錯誤碼定義衝突 - 需要統一標準 ⏳
|
||||
這是重要任務,建議本週內處理
|
||||
[自動使用 ./dl issue 記錄問題]
|
||||
```
|
||||
|
||||
## 🚨 緊急情況處理
|
||||
|
|
@ -437,22 +364,6 @@ Claude: 我發現用戶管理API和認證API的錯誤碼定義衝突...
|
|||
|
||||
---
|
||||
|
||||
## 🔔 協作通知規範 (新增 2025-09-09)
|
||||
|
||||
### 📢 任務完成通知
|
||||
- **完成任務後**: 必須使用 `say "I'm done"` 通知用戶
|
||||
- **適用情境**: 任何任務、分析、實作、整合工作完成後
|
||||
- **重要性**: 讓用戶知道可以查看結果或繼續下一步
|
||||
|
||||
### 🆘 求助通知
|
||||
- **需要用戶協助時**: 必須使用 `say "help"` 通知用戶
|
||||
- **適用情境**: 遇到無法自動決定的問題、需要確認方向時
|
||||
- **時機**: 在實際需要用戶回應前立即通知
|
||||
|
||||
**重要**: 這些通知習慣對協作體驗很重要,每次對話都必須遵循
|
||||
|
||||
---
|
||||
|
||||
**最後更新**: 2025-09-09
|
||||
**版本**: 3.1 - 整合統一任務管理和通知規範 (2025-09-09)
|
||||
**最後更新**: 2025-09-08
|
||||
**版本**: 3.0 - 整合專案管理系統和協作指南 (2025-09-08)
|
||||
**維護者**: Drama Ling 開發團隊
|
||||
|
|
@ -17,24 +17,6 @@
|
|||
- [ ] UI組件命名規範
|
||||
- [ ] 部分UI功能重複可能需要合併(多個Result相關UI)
|
||||
|
||||
### 📱 HTML原型缺失項目 (2025-09-09)
|
||||
|
||||
#### 🔧 高優先級 - 學習閉環核心功能
|
||||
- [ ] **語法錯誤訂正頁面** - 完成學習閉環的關鍵頁面,包含進度條和練習按鈕功能
|
||||
- [ ] **表達不順訂正頁面** - 語音發音訂正界面,與語法訂正配合完成訂正流程
|
||||
|
||||
#### 💻 中優先級 - 用戶體驗完善
|
||||
- [ ] **文字輸入彈窗界面** - 替換現有prompt()的完整彈窗UI設計
|
||||
- [ ] **頁面導航連接** - 完善結算→訂正→完成的完整用戶流程導航
|
||||
- [ ] **特殊情況處理** - 中文輸入聽不懂、無聲音提示等錯誤狀態處理
|
||||
|
||||
#### 🎨 低優先級 - 細節優化
|
||||
- [ ] **問題回報視窗** - 5選項回報機制的完整UI實現
|
||||
- [ ] **UI規格細節** - 語法檢查顏色標準、載入動畫等視覺細節
|
||||
- [ ] **通關協助頁面** - 寶石消耗協助功能的詳細界面設計
|
||||
|
||||
**影響評估**: 高優先級項目直接影響學習效果,中優先級影響用戶體驗流暢度
|
||||
|
||||
## 🤖 與 Claude 協作提醒
|
||||
|
||||
|
||||
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
**項目描述**: 建立完整的手機APP,實現AI驅動的語言學習功能
|
||||
|
||||
**當前階段**: ✅ 規格設計完成,準備核心功能開發
|
||||
**當前階段**: ✅ 環境配置完成,準備APP打包
|
||||
|
||||
#### 階段1: 環境配置 ✅
|
||||
- ✅ `ENV` Android Studio 安裝和配置 (2025-09-08)
|
||||
|
|
@ -44,23 +44,19 @@
|
|||
- ✅ `ENV` Android模擬器設置 (2025-09-08)
|
||||
- ✅ `MB` Flutter移動端配置調整 (2025-09-08)
|
||||
|
||||
#### 階段2: 規格設計完善 ✅
|
||||
#### 階段2: APP打包 ⏳
|
||||
- ✅ `MB` Android APK 生成配置 (2025-09-08)
|
||||
- ✅ `DOC` 02_design功能規格文檔完善 (2025-09-08)
|
||||
- 完成5個核心功能詳細規格文檔 (170頁)
|
||||
- 涵蓋43個UI畫面完整規格說明
|
||||
- 建立標準化文檔模板和品質框架
|
||||
- ⏳ `FE` 應用圖標和啟動畫面設計
|
||||
- ✅ `MB` APP權限配置 (語音、網路) (2025-09-08)
|
||||
- ✅ `TEST` 真實設備測試 (2025-09-08)
|
||||
|
||||
#### 階段3: 核心功能實現 ⏳
|
||||
- ⏳ `AI` 語音輸入功能實現 📋 規格已備妥
|
||||
- ⏳ `FE` 觸控操作優化 📋 規格已備妥
|
||||
- ⏳ `AI` 三維度評分系統 (語法、語意、流暢度) ✅ 規格完整
|
||||
- ⏳ `AI` 劇本對話系統 ✅ 規格完整
|
||||
- ⏳ `AI` 詞彙學習關卡系統 ✅ 規格完整
|
||||
- ⏳ `AI` 限時挑戰系統 (300秒) ✅ 規格完整
|
||||
- ⏳ `AI` 語音輸入功能實現
|
||||
- ⏳ `FE` 觸控操作優化
|
||||
- ⏳ `AI` 三維度評分系統 (語法、語意、流暢度)
|
||||
- ⏳ `AI` 劇本對話系統
|
||||
- ⏳ `AI` 詞彙學習關卡系統
|
||||
- ⏳ `AI` 限時挑戰系統 (300秒)
|
||||
|
||||
#### 階段4: 整合測試 ⏳
|
||||
- ⏳ `TEST` 功能整合測試
|
||||
|
|
@ -77,14 +73,9 @@
|
|||
**已完成**: 0 個
|
||||
|
||||
**執行項目統計**:
|
||||
- ⏳ 待執行: 8 個
|
||||
- ⏳ 待執行: 9 個
|
||||
- 🔄 進行中: 0 個
|
||||
- ✅ 已完成: 8 個
|
||||
|
||||
**開發準備狀況**:
|
||||
- 🎯 **規格完整項目**: 4個 (可立即開始開發)
|
||||
- 📋 **規格已備妥項目**: 2個 (需補充技術細節)
|
||||
- ⏳ **待規格項目**: 2個
|
||||
- ✅ 已完成: 7 個
|
||||
|
||||
---
|
||||
|
||||
143
TASKS.md
143
TASKS.md
|
|
@ -1,143 +0,0 @@
|
|||
# 🎯 Drama Ling 任務清單
|
||||
|
||||
## 📋 當前任務
|
||||
|
||||
### 🔥 緊急任務
|
||||
- [ ] 🎨 **語法錯誤訂正頁面** - 完成學習閉環的關鍵頁面 (預估4-6小時)
|
||||
- 📄 參考: [學習閉環系統](projects/learning-loop-system.md)
|
||||
- [ ] 🎨 **表達不順訂正頁面** - 語音發音訂正界面 (預估4-6小時)
|
||||
- 📄 參考: [語音訂正系統](projects/voice-correction-system.md)
|
||||
- [ ] 💎 **UI_SubscriptionPlans設計** - 訂閱方案選擇頁面,核心商業功能 (預估6-8小時)
|
||||
- 📄 參考: [UI設計任務清單](projects/ui-design-tasks.md)
|
||||
- [ ] 💳 **UI_PaymentFlow設計** - 付費流程頁面,提升轉換率 (預估6-8小時)
|
||||
- 📄 參考: [UI設計任務清單](projects/ui-design-tasks.md)
|
||||
- [ ] ⏰ **UI_TimedDialogue設計** - 300秒限時挑戰界面 (預估6-8小時)
|
||||
- 📄 參考: [UI設計任務清單](projects/ui-design-tasks.md)
|
||||
|
||||
### ⚠️ 重要任務
|
||||
- [ ] 📊 **資料庫schema設計** - 確定用戶表結構和關聯設計 (預估6-8小時)
|
||||
- 📄 參考: [資料庫設計專案](projects/database-design-project.md)
|
||||
- [ ] 🔐 **用戶認證流程細節** - 註冊、登入、權限管理流程 (預估4-6小時)
|
||||
- 📄 參考: [用戶認證系統](projects/user-auth-system.md)
|
||||
- [ ] 🏆 **UI_RankingDetail設計** - 排行榜詳情頁面,社群競爭功能 (預估4-6小時)
|
||||
- 📄 參考: [UI設計任務清單](projects/ui-design-tasks.md)
|
||||
- [ ] 🎁 **UI_RewardClaim設計** - 獎勵領取頁面,增強成就感 (預估3-4小時)
|
||||
- 📄 參考: [UI設計任務清單](projects/ui-design-tasks.md)
|
||||
- [ ] 📋 **UI_BonusMission_Main設計** - 每日任務主頁,提升活躍度 (預估4-6小時)
|
||||
- 📄 參考: [UI設計任務清單](projects/ui-design-tasks.md)
|
||||
|
||||
### 📝 一般任務
|
||||
- [ ] 🎨 **文字輸入彈窗界面** - 替換prompt()的完整UI (預估2-3小時)
|
||||
- 📄 參考: [UI組件系統](projects/ui-component-system.md)
|
||||
- [ ] 🔗 **頁面導航連接** - 完善用戶流程導航 (預估2-4小時)
|
||||
- 📄 參考: [導航系統](projects/navigation-system.md)
|
||||
- [ ] 🛠️ **特殊情況處理** - 錯誤狀態處理機制 (預估3-4小時)
|
||||
- 📄 參考: [錯誤處理系統](projects/error-handling-system.md)
|
||||
- [ ] 📚 **文檔格式統一** - 統一所有文檔格式規範 (預估2-3小時)
|
||||
- 📄 參考: [文檔規範](projects/documentation-standards.md)
|
||||
- [ ] 🏷️ **UI組件命名規範** - 建立統一命名標準 (預估1-2小時)
|
||||
- 📄 參考: [命名規範系統](projects/naming-convention-system.md)
|
||||
- [ ] 📚 **UI_ReviewCards設計** - 間隔複習卡片界面 (預估4-5小時)
|
||||
- 📄 參考: [UI設計任務清單](projects/ui-design-tasks.md)
|
||||
- [ ] 📊 **UI_ReviewProgress設計** - 複習進度統計頁面 (預估3-4小時)
|
||||
- 📄 參考: [UI設計任務清單](projects/ui-design-tasks.md)
|
||||
- [ ] 📅 **UI_ReviewSchedule設計** - 個人化複習排程頁面 (預估3-4小時)
|
||||
- 📄 參考: [UI設計任務清單](projects/ui-design-tasks.md)
|
||||
- [ ] 🏅 **UI_BadgeCollection設計** - 學習成就徽章收藏頁面 (預估3-4小時)
|
||||
- 📄 參考: [UI設計任務清單](projects/ui-design-tasks.md)
|
||||
- [ ] 💰 **UI_PurchasedContent設計** - 已購買內容管理頁面 (預估3-4小時)
|
||||
- 📄 參考: [UI設計任務清單](projects/ui-design-tasks.md)
|
||||
- [ ] 📺 **UI_AdOffer設計** - 廣告獎勵邀請頁面 (預估2-3小時)
|
||||
- 📄 參考: [UI設計任務清單](projects/ui-design-tasks.md)
|
||||
- [ ] 🎬 **UI_AdViewing設計** - 廣告觀看過程界面 (預估2-3小時)
|
||||
- 📄 參考: [UI設計任務清單](projects/ui-design-tasks.md)
|
||||
|
||||
### 💡 未來想法
|
||||
- [ ] 🔍 **UI功能重複評估** - 分析Result相關UI合併可能性
|
||||
- [ ] 🎨 **應用圖標和啟動畫面** - 品牌視覺設計
|
||||
- [ ] ❌ **錯誤處理UI組** - 錯誤、載入、網路異常、維護公告頁面 (預估6-8小時)
|
||||
- 📄 參考: [UI設計任務清單](projects/ui-design-tasks.md)
|
||||
- [ ] 🎨 **UI設計一致性檢查** - 17個新UI與71個現有UI的統一性檢查 (預估4-6小時)
|
||||
- 📄 參考: [UI設計任務清單](projects/ui-design-tasks.md)
|
||||
|
||||
---
|
||||
|
||||
## 📊 快速統計
|
||||
|
||||
**當前狀態**:
|
||||
- 🔥 緊急: 5個任務 (+3個UI設計)
|
||||
- ⚠️ 重要: 5個任務 (+3個UI設計)
|
||||
- 📝 一般: 12個任務 (+7個UI設計)
|
||||
- 💡 想法: 4個任務 (+2個UI設計)
|
||||
|
||||
**預估工作量**: 總計 98-132 小時 (包含17個UI設計任務)
|
||||
|
||||
---
|
||||
|
||||
## 📚 已完成任務 (最近10個)
|
||||
|
||||
### 2025-09-09 完成
|
||||
- [x] ✅ **CLAUDE.md文檔修正** - 修正章節重複、日期過時等結構性問題 ✅ (2025-09-09)
|
||||
- 📄 專案文檔: [CLAUDE.md問題分析](archive/2025-09-09/225744_claude-md-issues-analysis.md)
|
||||
- 🔧 解決內容: 章節編號重複、日期過時、工作流程不一致、檔案參考錯誤、問題管理流程混淆
|
||||
- [x] ✅ **API文檔系統重組** - 移動swagger-ui.html到docs/api/ (已完成)
|
||||
- [x] ✅ **專案管理系統整合** - 完成三層架構設計 (已完成)
|
||||
- [x] ✅ **情境學習界面實現** - 完成vocabulary.html的情境學習功能 (已完成)
|
||||
|
||||
### 2025-09-08 完成
|
||||
- [x] ✅ **02_design規格完善** - 建立5個核心功能詳細規格文檔 (已完成)
|
||||
- [x] ✅ **API模組化文檔** - 完成7個API模組建立 (已完成)
|
||||
- [x] ✅ **UI設計缺漏修復** - 100%完成40個缺失UI設計 (已完成)
|
||||
- [x] ✅ **系統整合指南** - 完成INTEGRATION_GUIDE.md (已完成)
|
||||
- [x] ✅ **工具系統更新** - ./dl命令支援新任務管理系統 (已完成)
|
||||
- [x] ✅ **CLAUDE工作指南更新** - 整合協作標準和通知系統 (已完成)
|
||||
- [x] ✅ **問題追蹤系統建立** - ISSUES.md完整問題管理機制 (已完成)
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 系統使用指南
|
||||
|
||||
### 查看任務
|
||||
```bash
|
||||
./dl task # 打開此任務管理文件
|
||||
./dl status # 查看任務統計
|
||||
./dl list # 快速查看待辦清單
|
||||
```
|
||||
|
||||
### 工作模式
|
||||
1. **討論階段**: 與Claude自由討論需求和想法
|
||||
2. **記錄階段**: Claude自動記錄任務到此系統,並創建對應專案詳細文檔
|
||||
3. **執行階段**: 查看此文件選擇任務批量執行
|
||||
4. **完成階段**: 標記任務完成 [x],任務自動移至已完成區域
|
||||
|
||||
### 任務格式說明
|
||||
```markdown
|
||||
- [ ] 🎯 **任務名稱** - 簡短描述 (預估時間)
|
||||
- 📄 參考: [專案詳細文檔](projects/project-name.md)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**建立日期**: 2025-09-09
|
||||
**最後更新**: 2025-09-09 (整合UI設計任務)
|
||||
**維護者**: Claude Code & Drama Ling Team
|
||||
|
||||
---
|
||||
|
||||
## 🎨 UI設計專案說明
|
||||
|
||||
本任務清單已整合 `projects/ui-design-tasks.md` 中的17個UI設計任務,分佈如下:
|
||||
|
||||
### 🔥 第一優先級 - 核心商業功能 (3個)
|
||||
- UI_SubscriptionPlans, UI_PaymentFlow, UI_TimedDialogue
|
||||
|
||||
### ⚠️ 第二優先級 - 學習體驗增強 (3個)
|
||||
- UI_RankingDetail, UI_RewardClaim, UI_BonusMission_Main
|
||||
|
||||
### 📝 第三優先級 - 學習功能完善 (7個)
|
||||
- UI_ReviewCards, UI_ReviewProgress, UI_ReviewSchedule, UI_BadgeCollection, UI_PurchasedContent, UI_AdOffer, UI_AdViewing
|
||||
|
||||
### 💡 第四優先級 - 輔助功能 (4個)
|
||||
- 錯誤處理UI組, UI設計一致性檢查
|
||||
|
||||
**設計目標**: 完成剩餘17個UI介面,從71/88 (81%) 達成100%完整覆蓋
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
# Drama Ling Applications
|
||||
|
||||
本目錄包含 Drama Ling 專案的所有應用程式。
|
||||
|
||||
## 應用程式架構
|
||||
|
||||
```
|
||||
apps/
|
||||
├── web/ # Vue.js Web 前端應用
|
||||
├── mobile/ # Flutter 移動端應用
|
||||
└── backend/ # .NET Core 後端 API
|
||||
```
|
||||
|
||||
## 開發狀態
|
||||
|
||||
| 應用程式 | 狀態 | 技術棧 | 說明 |
|
||||
|---------|------|--------|------|
|
||||
| Web | ✅ 開發中 | Vue.js + Quasar | Web 前端界面 |
|
||||
| Mobile | ✅ 開發中 | Flutter + Riverpod | 跨平台移動應用 |
|
||||
| Backend | ✅ 開發中 | .NET Core + EF Core | REST API 服務 |
|
||||
|
||||
## 開發指南
|
||||
|
||||
各應用程式的詳細開發文檔請參考:
|
||||
- 技術文檔:`../docs/04_technical/`
|
||||
- 專案規格:`../projects/`
|
||||
- 任務管理:`../TASKS.md`
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
# Drama Ling Backend API
|
||||
|
||||
.NET Core 後端 API 服務
|
||||
|
||||
## 技術棧
|
||||
- **.NET 8**: 跨平台框架
|
||||
- **ASP.NET Core Web API**: RESTful API
|
||||
- **Entity Framework Core**: ORM 資料庫存取
|
||||
- **PostgreSQL**: 主要資料庫
|
||||
- **Redis**: 快取和會話管理
|
||||
- **JWT**: 身份驗證
|
||||
|
||||
## 專案結構
|
||||
```
|
||||
backend/
|
||||
├── DramaLing.API/ # Web API 專案
|
||||
├── DramaLing.Application/ # 應用服務層
|
||||
├── DramaLing.Core/ # 領域模型層
|
||||
├── DramaLing.Infrastructure/ # 基礎設施層
|
||||
├── DramaLing.Tests/ # 測試專案
|
||||
└── DramaLing.sln # 解決方案檔
|
||||
```
|
||||
|
||||
## 快速開始
|
||||
|
||||
### 1. 安裝相依套件
|
||||
```bash
|
||||
dotnet restore
|
||||
```
|
||||
|
||||
### 2. 設定資料庫
|
||||
```bash
|
||||
# 建立資料庫
|
||||
dotnet ef database update --project DramaLing.Infrastructure --startup-project DramaLing.API
|
||||
```
|
||||
|
||||
### 3. 啟動開發服務器
|
||||
```bash
|
||||
dotnet run --project DramaLing.API
|
||||
# API: http://localhost:5000
|
||||
# Swagger: http://localhost:5000
|
||||
```
|
||||
|
||||
## 開發指南
|
||||
詳細開發文檔請參考:`../../docs/04_technical/`
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
# Drama Ling Mobile App
|
||||
|
||||
Flutter 移動端應用程式
|
||||
|
||||
## 技術棧
|
||||
- **Flutter 3.16+**: 跨平台框架
|
||||
- **Dart 3.0+**: 程式語言
|
||||
- **Riverpod**: 狀態管理
|
||||
- **Go Router**: 導航路由
|
||||
- **Dio + Retrofit**: HTTP 客戶端
|
||||
- **Hive**: 本地資料存儲
|
||||
- **Material 3**: UI 設計系統
|
||||
|
||||
## 專案結構
|
||||
```
|
||||
mobile/
|
||||
├── lib/
|
||||
│ ├── core/ # 核心功能 (常數、工具、服務)
|
||||
│ ├── features/ # 功能模組 (認證、學習、對話等)
|
||||
│ └── shared/ # 共用組件 (Widget、模型、Provider)
|
||||
└── pubspec.yaml # Flutter 專案配置
|
||||
```
|
||||
|
||||
## 快速開始
|
||||
|
||||
### 1. 安裝相依套件
|
||||
```bash
|
||||
flutter pub get
|
||||
```
|
||||
|
||||
### 2. 程式碼生成
|
||||
```bash
|
||||
dart run build_runner build
|
||||
```
|
||||
|
||||
### 3. 啟動應用
|
||||
```bash
|
||||
flutter run
|
||||
# 需要模擬器或實體裝置
|
||||
```
|
||||
|
||||
## 開發指南
|
||||
詳細開發文檔請參考:`../../docs/04_technical/`
|
||||
|
|
@ -1,355 +0,0 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:speech_to_text/speech_recognition_error.dart';
|
||||
import 'package:speech_to_text/speech_recognition_result.dart';
|
||||
import 'package:speech_to_text/speech_to_text.dart';
|
||||
|
||||
/// AI語音識別服務
|
||||
///
|
||||
/// 提供完整的語音識別功能,支援:
|
||||
/// - 實時語音轉文字
|
||||
/// - 多語言支援(中文、英文)
|
||||
/// - 音頻權限管理
|
||||
/// - 錯誤處理與重試機制
|
||||
/// - 音量監測
|
||||
class VoiceRecognitionService {
|
||||
static final VoiceRecognitionService _instance = VoiceRecognitionService._internal();
|
||||
factory VoiceRecognitionService() => _instance;
|
||||
VoiceRecognitionService._internal();
|
||||
|
||||
final SpeechToText _speechToText = SpeechToText();
|
||||
|
||||
// 語音識別狀態
|
||||
bool _isInitialized = false;
|
||||
bool _isListening = false;
|
||||
bool _isAvailable = false;
|
||||
|
||||
// 識別結果回調
|
||||
final StreamController<VoiceRecognitionResult> _resultController =
|
||||
StreamController<VoiceRecognitionResult>.broadcast();
|
||||
|
||||
// 音量回調
|
||||
final StreamController<double> _soundLevelController =
|
||||
StreamController<double>.broadcast();
|
||||
|
||||
// 狀態回調
|
||||
final StreamController<VoiceRecognitionState> _stateController =
|
||||
StreamController<VoiceRecognitionState>.broadcast();
|
||||
|
||||
// 支援的語言
|
||||
static const Map<String, String> supportedLanguages = {
|
||||
'zh-TW': '繁體中文',
|
||||
'zh-CN': '簡體中文',
|
||||
'en-US': 'English (US)',
|
||||
'en-GB': 'English (UK)',
|
||||
};
|
||||
|
||||
// Getters
|
||||
bool get isInitialized => _isInitialized;
|
||||
bool get isListening => _isListening;
|
||||
bool get isAvailable => _isAvailable;
|
||||
|
||||
// Streams
|
||||
Stream<VoiceRecognitionResult> get resultStream => _resultController.stream;
|
||||
Stream<double> get soundLevelStream => _soundLevelController.stream;
|
||||
Stream<VoiceRecognitionState> get stateStream => _stateController.stream;
|
||||
|
||||
/// 初始化語音識別服務
|
||||
Future<bool> initialize() async {
|
||||
try {
|
||||
// 檢查並請求麥克風權限
|
||||
final permissionStatus = await _requestMicrophonePermission();
|
||||
if (!permissionStatus) {
|
||||
debugPrint('VoiceRecognitionService: 麥克風權限被拒絕');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 初始化語音識別引擎
|
||||
_isAvailable = await _speechToText.initialize(
|
||||
onError: _onError,
|
||||
onStatus: _onStatus,
|
||||
);
|
||||
|
||||
if (_isAvailable) {
|
||||
_isInitialized = true;
|
||||
_stateController.add(VoiceRecognitionState.initialized);
|
||||
debugPrint('VoiceRecognitionService: 初始化成功');
|
||||
return true;
|
||||
} else {
|
||||
debugPrint('VoiceRecognitionService: 語音識別不可用');
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('VoiceRecognitionService: 初始化失敗 - $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 開始語音識別
|
||||
Future<bool> startListening({
|
||||
String languageId = 'zh-TW',
|
||||
Duration timeout = const Duration(seconds: 30),
|
||||
bool partialResults = true,
|
||||
}) async {
|
||||
if (!_isInitialized || !_isAvailable) {
|
||||
debugPrint('VoiceRecognitionService: 服務未初始化或不可用');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_isListening) {
|
||||
debugPrint('VoiceRecognitionService: 已在監聽中');
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
await _speechToText.listen(
|
||||
onResult: _onResult,
|
||||
listenFor: timeout,
|
||||
pauseFor: const Duration(seconds: 3),
|
||||
partialResults: partialResults,
|
||||
localeId: languageId,
|
||||
onSoundLevelChange: _onSoundLevelChange,
|
||||
listenMode: ListenMode.confirmation,
|
||||
);
|
||||
|
||||
_isListening = true;
|
||||
_stateController.add(VoiceRecognitionState.listening);
|
||||
debugPrint('VoiceRecognitionService: 開始監聽');
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('VoiceRecognitionService: 開始監聽失敗 - $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 停止語音識別
|
||||
Future<void> stopListening() async {
|
||||
if (!_isListening) return;
|
||||
|
||||
try {
|
||||
await _speechToText.stop();
|
||||
_isListening = false;
|
||||
_stateController.add(VoiceRecognitionState.stopped);
|
||||
debugPrint('VoiceRecognitionService: 停止監聽');
|
||||
} catch (e) {
|
||||
debugPrint('VoiceRecognitionService: 停止監聽失敗 - $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 取消語音識別
|
||||
Future<void> cancel() async {
|
||||
if (!_isListening) return;
|
||||
|
||||
try {
|
||||
await _speechToText.cancel();
|
||||
_isListening = false;
|
||||
_stateController.add(VoiceRecognitionState.cancelled);
|
||||
debugPrint('VoiceRecognitionService: 取消監聽');
|
||||
} catch (e) {
|
||||
debugPrint('VoiceRecognitionService: 取消監聽失敗 - $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 獲取支援的語言
|
||||
Future<List<LocaleName>> getAvailableLanguages() async {
|
||||
if (!_isInitialized) return [];
|
||||
return await _speechToText.locales();
|
||||
}
|
||||
|
||||
/// 檢查特定語言是否支援
|
||||
Future<bool> isLanguageSupported(String languageId) async {
|
||||
final locales = await getAvailableLanguages();
|
||||
return locales.any((locale) => locale.localeId == languageId);
|
||||
}
|
||||
|
||||
/// 請求麥克風權限
|
||||
Future<bool> _requestMicrophonePermission() async {
|
||||
final status = await Permission.microphone.status;
|
||||
|
||||
if (status.isGranted) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (status.isDenied) {
|
||||
final result = await Permission.microphone.request();
|
||||
return result.isGranted;
|
||||
}
|
||||
|
||||
if (status.isPermanentlyDenied) {
|
||||
await openAppSettings();
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 處理識別結果
|
||||
void _onResult(SpeechRecognitionResult result) {
|
||||
final voiceResult = VoiceRecognitionResult(
|
||||
recognizedWords: result.recognizedWords,
|
||||
confidence: result.confidence,
|
||||
isFinal: result.finalResult,
|
||||
alternatives: result.alternates.map((alt) =>
|
||||
VoiceAlternative(
|
||||
text: alt.recognizedWords,
|
||||
confidence: alt.confidence,
|
||||
)
|
||||
).toList(),
|
||||
);
|
||||
|
||||
_resultController.add(voiceResult);
|
||||
|
||||
if (result.finalResult) {
|
||||
debugPrint('VoiceRecognitionService: 最終結果 - ${result.recognizedWords}');
|
||||
} else {
|
||||
debugPrint('VoiceRecognitionService: 部分結果 - ${result.recognizedWords}');
|
||||
}
|
||||
}
|
||||
|
||||
/// 處理錯誤
|
||||
void _onError(SpeechRecognitionError error) {
|
||||
debugPrint('VoiceRecognitionService: 錯誤 - ${error.errorMsg}');
|
||||
|
||||
final errorType = _mapErrorType(error.errorMsg);
|
||||
_stateController.add(VoiceRecognitionState.error(errorType, error.errorMsg));
|
||||
|
||||
_isListening = false;
|
||||
}
|
||||
|
||||
/// 處理狀態變化
|
||||
void _onStatus(String status) {
|
||||
debugPrint('VoiceRecognitionService: 狀態變化 - $status');
|
||||
|
||||
switch (status) {
|
||||
case 'listening':
|
||||
_isListening = true;
|
||||
_stateController.add(VoiceRecognitionState.listening);
|
||||
break;
|
||||
case 'notListening':
|
||||
_isListening = false;
|
||||
_stateController.add(VoiceRecognitionState.stopped);
|
||||
break;
|
||||
case 'done':
|
||||
_isListening = false;
|
||||
_stateController.add(VoiceRecognitionState.completed);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// 處理音量變化
|
||||
void _onSoundLevelChange(double level) {
|
||||
_soundLevelController.add(level);
|
||||
}
|
||||
|
||||
/// 映射錯誤類型
|
||||
VoiceRecognitionErrorType _mapErrorType(String errorMsg) {
|
||||
if (errorMsg.contains('network')) {
|
||||
return VoiceRecognitionErrorType.network;
|
||||
} else if (errorMsg.contains('audio')) {
|
||||
return VoiceRecognitionErrorType.audio;
|
||||
} else if (errorMsg.contains('permission')) {
|
||||
return VoiceRecognitionErrorType.permission;
|
||||
} else if (errorMsg.contains('timeout')) {
|
||||
return VoiceRecognitionErrorType.timeout;
|
||||
} else {
|
||||
return VoiceRecognitionErrorType.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理資源
|
||||
void dispose() {
|
||||
_resultController.close();
|
||||
_soundLevelController.close();
|
||||
_stateController.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// 語音識別結果
|
||||
class VoiceRecognitionResult {
|
||||
final String recognizedWords;
|
||||
final double confidence;
|
||||
final bool isFinal;
|
||||
final List<VoiceAlternative> alternatives;
|
||||
|
||||
VoiceRecognitionResult({
|
||||
required this.recognizedWords,
|
||||
required this.confidence,
|
||||
required this.isFinal,
|
||||
this.alternatives = const [],
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'VoiceRecognitionResult(words: $recognizedWords, confidence: $confidence, isFinal: $isFinal)';
|
||||
}
|
||||
}
|
||||
|
||||
/// 語音識別替代結果
|
||||
class VoiceAlternative {
|
||||
final String text;
|
||||
final double confidence;
|
||||
|
||||
VoiceAlternative({
|
||||
required this.text,
|
||||
required this.confidence,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'VoiceAlternative(text: $text, confidence: $confidence)';
|
||||
}
|
||||
}
|
||||
|
||||
/// 語音識別狀態
|
||||
class VoiceRecognitionState {
|
||||
final VoiceRecognitionStatus status;
|
||||
final VoiceRecognitionErrorType? errorType;
|
||||
final String? errorMessage;
|
||||
|
||||
VoiceRecognitionState._(this.status, [this.errorType, this.errorMessage]);
|
||||
|
||||
static VoiceRecognitionState get uninitialized =>
|
||||
VoiceRecognitionState._(VoiceRecognitionStatus.uninitialized);
|
||||
|
||||
static VoiceRecognitionState get initialized =>
|
||||
VoiceRecognitionState._(VoiceRecognitionStatus.initialized);
|
||||
|
||||
static VoiceRecognitionState get listening =>
|
||||
VoiceRecognitionState._(VoiceRecognitionStatus.listening);
|
||||
|
||||
static VoiceRecognitionState get stopped =>
|
||||
VoiceRecognitionState._(VoiceRecognitionStatus.stopped);
|
||||
|
||||
static VoiceRecognitionState get completed =>
|
||||
VoiceRecognitionState._(VoiceRecognitionStatus.completed);
|
||||
|
||||
static VoiceRecognitionState get cancelled =>
|
||||
VoiceRecognitionState._(VoiceRecognitionStatus.cancelled);
|
||||
|
||||
static VoiceRecognitionState error(VoiceRecognitionErrorType errorType, String message) =>
|
||||
VoiceRecognitionState._(VoiceRecognitionStatus.error, errorType, message);
|
||||
|
||||
bool get hasError => status == VoiceRecognitionStatus.error;
|
||||
}
|
||||
|
||||
/// 語音識別狀態枚舉
|
||||
enum VoiceRecognitionStatus {
|
||||
uninitialized,
|
||||
initialized,
|
||||
listening,
|
||||
stopped,
|
||||
completed,
|
||||
cancelled,
|
||||
error,
|
||||
}
|
||||
|
||||
/// 語音識別錯誤類型
|
||||
enum VoiceRecognitionErrorType {
|
||||
network,
|
||||
audio,
|
||||
permission,
|
||||
timeout,
|
||||
unknown,
|
||||
}
|
||||
|
|
@ -1,547 +0,0 @@
|
|||
/// 對話場景模型
|
||||
class DialogueScene {
|
||||
final String id;
|
||||
final String name;
|
||||
final String description;
|
||||
final String backgroundImageUrl;
|
||||
final String characterId;
|
||||
final String difficultyLevel;
|
||||
final List<String> tags;
|
||||
final Map<String, dynamic> metadata;
|
||||
|
||||
DialogueScene({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.backgroundImageUrl,
|
||||
required this.characterId,
|
||||
required this.difficultyLevel,
|
||||
this.tags = const [],
|
||||
this.metadata = const {},
|
||||
});
|
||||
|
||||
factory DialogueScene.fromJson(Map<String, dynamic> json) {
|
||||
return DialogueScene(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
description: json['description'] as String,
|
||||
backgroundImageUrl: json['backgroundImageUrl'] as String,
|
||||
characterId: json['characterId'] as String,
|
||||
difficultyLevel: json['difficultyLevel'] as String,
|
||||
tags: List<String>.from(json['tags'] ?? []),
|
||||
metadata: json['metadata'] as Map<String, dynamic>? ?? {},
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'description': description,
|
||||
'backgroundImageUrl': backgroundImageUrl,
|
||||
'characterId': characterId,
|
||||
'difficultyLevel': difficultyLevel,
|
||||
'tags': tags,
|
||||
'metadata': metadata,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 對話角色模型
|
||||
class DialogueCharacter {
|
||||
final String id;
|
||||
final String name;
|
||||
final String description;
|
||||
final String avatarUrl;
|
||||
final String personality;
|
||||
final String role;
|
||||
final String background;
|
||||
final List<String> specialities;
|
||||
final Map<String, String> localizedNames;
|
||||
|
||||
DialogueCharacter({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.avatarUrl,
|
||||
required this.personality,
|
||||
required this.role,
|
||||
required this.background,
|
||||
this.specialities = const [],
|
||||
this.localizedNames = const {},
|
||||
});
|
||||
|
||||
factory DialogueCharacter.fromJson(Map<String, dynamic> json) {
|
||||
return DialogueCharacter(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
description: json['description'] as String,
|
||||
avatarUrl: json['avatarUrl'] as String,
|
||||
personality: json['personality'] as String,
|
||||
role: json['role'] as String,
|
||||
background: json['background'] as String,
|
||||
specialities: List<String>.from(json['specialities'] ?? []),
|
||||
localizedNames: Map<String, String>.from(json['localizedNames'] ?? {}),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'description': description,
|
||||
'avatarUrl': avatarUrl,
|
||||
'personality': personality,
|
||||
'role': role,
|
||||
'background': background,
|
||||
'specialities': specialities,
|
||||
'localizedNames': localizedNames,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 對話消息模型
|
||||
class DialogueMessage {
|
||||
final String id;
|
||||
final String content;
|
||||
final bool isUser;
|
||||
final DateTime timestamp;
|
||||
final DialogueMessageType type;
|
||||
final Map<String, dynamic>? metadata;
|
||||
final String? audioUrl;
|
||||
final double? confidence;
|
||||
|
||||
DialogueMessage({
|
||||
required this.id,
|
||||
required this.content,
|
||||
required this.isUser,
|
||||
required this.timestamp,
|
||||
this.type = DialogueMessageType.text,
|
||||
this.metadata,
|
||||
this.audioUrl,
|
||||
this.confidence,
|
||||
});
|
||||
|
||||
factory DialogueMessage.fromJson(Map<String, dynamic> json) {
|
||||
return DialogueMessage(
|
||||
id: json['id'] as String,
|
||||
content: json['content'] as String,
|
||||
isUser: json['isUser'] as bool,
|
||||
timestamp: DateTime.parse(json['timestamp'] as String),
|
||||
type: DialogueMessageType.values.firstWhere(
|
||||
(e) => e.toString() == 'DialogueMessageType.${json['type']}',
|
||||
orElse: () => DialogueMessageType.text,
|
||||
),
|
||||
metadata: json['metadata'] as Map<String, dynamic>?,
|
||||
audioUrl: json['audioUrl'] as String?,
|
||||
confidence: json['confidence'] as double?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'content': content,
|
||||
'isUser': isUser,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
'type': type.toString().split('.').last,
|
||||
'metadata': metadata,
|
||||
'audioUrl': audioUrl,
|
||||
'confidence': confidence,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 對話消息類型
|
||||
enum DialogueMessageType {
|
||||
text,
|
||||
audio,
|
||||
system,
|
||||
hint,
|
||||
}
|
||||
|
||||
/// 對話任務模型
|
||||
class DialogueTask {
|
||||
final String id;
|
||||
final String title;
|
||||
final String description;
|
||||
final DialogueTaskType type;
|
||||
final Map<String, dynamic> requirements;
|
||||
final double progress;
|
||||
final bool isCompleted;
|
||||
final int maxAttempts;
|
||||
final int currentAttempts;
|
||||
final String? completionMessage;
|
||||
|
||||
DialogueTask({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.type,
|
||||
required this.requirements,
|
||||
this.progress = 0.0,
|
||||
this.isCompleted = false,
|
||||
this.maxAttempts = 3,
|
||||
this.currentAttempts = 0,
|
||||
this.completionMessage,
|
||||
});
|
||||
|
||||
factory DialogueTask.fromJson(Map<String, dynamic> json) {
|
||||
return DialogueTask(
|
||||
id: json['id'] as String,
|
||||
title: json['title'] as String,
|
||||
description: json['description'] as String,
|
||||
type: DialogueTaskType.values.firstWhere(
|
||||
(e) => e.toString() == 'DialogueTaskType.${json['type']}',
|
||||
orElse: () => DialogueTaskType.conversation,
|
||||
),
|
||||
requirements: json['requirements'] as Map<String, dynamic>,
|
||||
progress: json['progress'] as double? ?? 0.0,
|
||||
isCompleted: json['isCompleted'] as bool? ?? false,
|
||||
maxAttempts: json['maxAttempts'] as int? ?? 3,
|
||||
currentAttempts: json['currentAttempts'] as int? ?? 0,
|
||||
completionMessage: json['completionMessage'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'description': description,
|
||||
'type': type.toString().split('.').last,
|
||||
'requirements': requirements,
|
||||
'progress': progress,
|
||||
'isCompleted': isCompleted,
|
||||
'maxAttempts': maxAttempts,
|
||||
'currentAttempts': currentAttempts,
|
||||
'completionMessage': completionMessage,
|
||||
};
|
||||
}
|
||||
|
||||
DialogueTask copyWith({
|
||||
String? id,
|
||||
String? title,
|
||||
String? description,
|
||||
DialogueTaskType? type,
|
||||
Map<String, dynamic>? requirements,
|
||||
double? progress,
|
||||
bool? isCompleted,
|
||||
int? maxAttempts,
|
||||
int? currentAttempts,
|
||||
String? completionMessage,
|
||||
}) {
|
||||
return DialogueTask(
|
||||
id: id ?? this.id,
|
||||
title: title ?? this.title,
|
||||
description: description ?? this.description,
|
||||
type: type ?? this.type,
|
||||
requirements: requirements ?? this.requirements,
|
||||
progress: progress ?? this.progress,
|
||||
isCompleted: isCompleted ?? this.isCompleted,
|
||||
maxAttempts: maxAttempts ?? this.maxAttempts,
|
||||
currentAttempts: currentAttempts ?? this.currentAttempts,
|
||||
completionMessage: completionMessage ?? this.completionMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 對話任務類型
|
||||
enum DialogueTaskType {
|
||||
conversation, // 完成對話
|
||||
vocabulary, // 使用指定詞彙
|
||||
grammar, // 語法練習
|
||||
pronunciation, // 發音練習
|
||||
comprehension, // 理解測試
|
||||
}
|
||||
|
||||
/// 對話分析結果模型
|
||||
class DialogueAnalysis {
|
||||
final String id;
|
||||
final String userReply;
|
||||
final DateTime timestamp;
|
||||
|
||||
// 三維度評分
|
||||
final double grammarScore;
|
||||
final double semanticsScore;
|
||||
final double fluencyScore;
|
||||
|
||||
// 詳細分析
|
||||
final List<GrammarIssue> grammarIssues;
|
||||
final List<String> usedVocabulary;
|
||||
final List<String> missedVocabulary;
|
||||
final List<String> suggestions;
|
||||
|
||||
// 任務相關
|
||||
final double? taskProgress;
|
||||
final bool isDialogueComplete;
|
||||
|
||||
// 其他
|
||||
final Map<String, dynamic> metadata;
|
||||
|
||||
DialogueAnalysis({
|
||||
required this.id,
|
||||
required this.userReply,
|
||||
required this.timestamp,
|
||||
required this.grammarScore,
|
||||
required this.semanticsScore,
|
||||
required this.fluencyScore,
|
||||
this.grammarIssues = const [],
|
||||
this.usedVocabulary = const [],
|
||||
this.missedVocabulary = const [],
|
||||
this.suggestions = const [],
|
||||
this.taskProgress,
|
||||
this.isDialogueComplete = false,
|
||||
this.metadata = const {},
|
||||
});
|
||||
|
||||
factory DialogueAnalysis.fromJson(Map<String, dynamic> json) {
|
||||
return DialogueAnalysis(
|
||||
id: json['id'] as String,
|
||||
userReply: json['userReply'] as String,
|
||||
timestamp: DateTime.parse(json['timestamp'] as String),
|
||||
grammarScore: json['grammarScore'] as double,
|
||||
semanticsScore: json['semanticsScore'] as double,
|
||||
fluencyScore: json['fluencyScore'] as double,
|
||||
grammarIssues: (json['grammarIssues'] as List?)
|
||||
?.map((e) => GrammarIssue.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ?? [],
|
||||
usedVocabulary: List<String>.from(json['usedVocabulary'] ?? []),
|
||||
missedVocabulary: List<String>.from(json['missedVocabulary'] ?? []),
|
||||
suggestions: List<String>.from(json['suggestions'] ?? []),
|
||||
taskProgress: json['taskProgress'] as double?,
|
||||
isDialogueComplete: json['isDialogueComplete'] as bool? ?? false,
|
||||
metadata: json['metadata'] as Map<String, dynamic>? ?? {},
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'userReply': userReply,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
'grammarScore': grammarScore,
|
||||
'semanticsScore': semanticsScore,
|
||||
'fluencyScore': fluencyScore,
|
||||
'grammarIssues': grammarIssues.map((e) => e.toJson()).toList(),
|
||||
'usedVocabulary': usedVocabulary,
|
||||
'missedVocabulary': missedVocabulary,
|
||||
'suggestions': suggestions,
|
||||
'taskProgress': taskProgress,
|
||||
'isDialogueComplete': isDialogueComplete,
|
||||
'metadata': metadata,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 語法問題模型
|
||||
class GrammarIssue {
|
||||
final String type;
|
||||
final String description;
|
||||
final String originalText;
|
||||
final String suggestedText;
|
||||
final int position;
|
||||
final int length;
|
||||
final GrammarIssueSeverity severity;
|
||||
|
||||
GrammarIssue({
|
||||
required this.type,
|
||||
required this.description,
|
||||
required this.originalText,
|
||||
required this.suggestedText,
|
||||
required this.position,
|
||||
required this.length,
|
||||
required this.severity,
|
||||
});
|
||||
|
||||
factory GrammarIssue.fromJson(Map<String, dynamic> json) {
|
||||
return GrammarIssue(
|
||||
type: json['type'] as String,
|
||||
description: json['description'] as String,
|
||||
originalText: json['originalText'] as String,
|
||||
suggestedText: json['suggestedText'] as String,
|
||||
position: json['position'] as int,
|
||||
length: json['length'] as int,
|
||||
severity: GrammarIssueSeverity.values.firstWhere(
|
||||
(e) => e.toString() == 'GrammarIssueSeverity.${json['severity']}',
|
||||
orElse: () => GrammarIssueSeverity.minor,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'type': type,
|
||||
'description': description,
|
||||
'originalText': originalText,
|
||||
'suggestedText': suggestedText,
|
||||
'position': position,
|
||||
'length': length,
|
||||
'severity': severity.toString().split('.').last,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 語法問題嚴重程度
|
||||
enum GrammarIssueSeverity {
|
||||
minor,
|
||||
moderate,
|
||||
major,
|
||||
critical,
|
||||
}
|
||||
|
||||
/// 對話最終得分模型
|
||||
class DialogueScore {
|
||||
final double grammarScore;
|
||||
final double semanticsScore;
|
||||
final double fluencyScore;
|
||||
final double taskBonus;
|
||||
final double vocabularyBonus;
|
||||
final double timeBonus;
|
||||
final double totalScore;
|
||||
final int starRating;
|
||||
final DateTime timestamp;
|
||||
final Map<String, dynamic> breakdown;
|
||||
|
||||
DialogueScore({
|
||||
required this.grammarScore,
|
||||
required this.semanticsScore,
|
||||
required this.fluencyScore,
|
||||
required this.taskBonus,
|
||||
required this.vocabularyBonus,
|
||||
required this.timeBonus,
|
||||
required this.totalScore,
|
||||
required this.starRating,
|
||||
DateTime? timestamp,
|
||||
this.breakdown = const {},
|
||||
}) : timestamp = timestamp ?? DateTime.now();
|
||||
|
||||
factory DialogueScore.fromJson(Map<String, dynamic> json) {
|
||||
return DialogueScore(
|
||||
grammarScore: json['grammarScore'] as double,
|
||||
semanticsScore: json['semanticsScore'] as double,
|
||||
fluencyScore: json['fluencyScore'] as double,
|
||||
taskBonus: json['taskBonus'] as double,
|
||||
vocabularyBonus: json['vocabularyBonus'] as double,
|
||||
timeBonus: json['timeBonus'] as double,
|
||||
totalScore: json['totalScore'] as double,
|
||||
starRating: json['starRating'] as int,
|
||||
timestamp: DateTime.parse(json['timestamp'] as String),
|
||||
breakdown: json['breakdown'] as Map<String, dynamic>? ?? {},
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'grammarScore': grammarScore,
|
||||
'semanticsScore': semanticsScore,
|
||||
'fluencyScore': fluencyScore,
|
||||
'taskBonus': taskBonus,
|
||||
'vocabularyBonus': vocabularyBonus,
|
||||
'timeBonus': timeBonus,
|
||||
'totalScore': totalScore,
|
||||
'starRating': starRating,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
'breakdown': breakdown,
|
||||
};
|
||||
}
|
||||
|
||||
String get grade {
|
||||
if (totalScore >= 90) return 'A+';
|
||||
if (totalScore >= 80) return 'A';
|
||||
if (totalScore >= 70) return 'B';
|
||||
if (totalScore >= 60) return 'C';
|
||||
if (totalScore >= 50) return 'D';
|
||||
return 'F';
|
||||
}
|
||||
|
||||
String get comment {
|
||||
switch (starRating) {
|
||||
case 3:
|
||||
return '優秀!你的表現非常出色!';
|
||||
case 2:
|
||||
return '很好!繼續努力就能更進一步!';
|
||||
case 1:
|
||||
return '不錯!還有改進的空間。';
|
||||
default:
|
||||
return '需要更多練習,加油!';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 詞彙項目模型
|
||||
class VocabularyItem {
|
||||
final String id;
|
||||
final String word;
|
||||
final String definition;
|
||||
final String pronunciation;
|
||||
final List<String> examples;
|
||||
final String category;
|
||||
final int difficulty;
|
||||
final bool isRequired;
|
||||
final bool isUsed;
|
||||
|
||||
VocabularyItem({
|
||||
required this.id,
|
||||
required this.word,
|
||||
required this.definition,
|
||||
required this.pronunciation,
|
||||
this.examples = const [],
|
||||
this.category = '',
|
||||
this.difficulty = 1,
|
||||
this.isRequired = false,
|
||||
this.isUsed = false,
|
||||
});
|
||||
|
||||
factory VocabularyItem.fromJson(Map<String, dynamic> json) {
|
||||
return VocabularyItem(
|
||||
id: json['id'] as String,
|
||||
word: json['word'] as String,
|
||||
definition: json['definition'] as String,
|
||||
pronunciation: json['pronunciation'] as String,
|
||||
examples: List<String>.from(json['examples'] ?? []),
|
||||
category: json['category'] as String? ?? '',
|
||||
difficulty: json['difficulty'] as int? ?? 1,
|
||||
isRequired: json['isRequired'] as bool? ?? false,
|
||||
isUsed: json['isUsed'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'word': word,
|
||||
'definition': definition,
|
||||
'pronunciation': pronunciation,
|
||||
'examples': examples,
|
||||
'category': category,
|
||||
'difficulty': difficulty,
|
||||
'isRequired': isRequired,
|
||||
'isUsed': isUsed,
|
||||
};
|
||||
}
|
||||
|
||||
VocabularyItem copyWith({
|
||||
String? id,
|
||||
String? word,
|
||||
String? definition,
|
||||
String? pronunciation,
|
||||
List<String>? examples,
|
||||
String? category,
|
||||
int? difficulty,
|
||||
bool? isRequired,
|
||||
bool? isUsed,
|
||||
}) {
|
||||
return VocabularyItem(
|
||||
id: id ?? this.id,
|
||||
word: word ?? this.word,
|
||||
definition: definition ?? this.definition,
|
||||
pronunciation: pronunciation ?? this.pronunciation,
|
||||
examples: examples ?? this.examples,
|
||||
category: category ?? this.category,
|
||||
difficulty: difficulty ?? this.difficulty,
|
||||
isRequired: isRequired ?? this.isRequired,
|
||||
isUsed: isUsed ?? this.isUsed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,390 +0,0 @@
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../models/dialogue_models.dart';
|
||||
import '../services/dialogue_service.dart';
|
||||
|
||||
/// 對話狀態提供者
|
||||
final dialogueProvider = StateNotifierProvider<DialogueNotifier, DialogueState>((ref) {
|
||||
final dialogueService = ref.watch(dialogueServiceProvider);
|
||||
return DialogueNotifier(dialogueService);
|
||||
});
|
||||
|
||||
/// 對話服務提供者
|
||||
final dialogueServiceProvider = Provider<DialogueService>((ref) {
|
||||
return DialogueService();
|
||||
});
|
||||
|
||||
/// 對話狀態管理
|
||||
class DialogueNotifier extends StateNotifier<DialogueState> {
|
||||
final DialogueService _dialogueService;
|
||||
|
||||
DialogueNotifier(this._dialogueService) : super(DialogueState.initial());
|
||||
|
||||
/// 初始化對話
|
||||
Future<void> initializeDialogue({
|
||||
required String scenarioId,
|
||||
required String levelId,
|
||||
bool isTimeChallenge = false,
|
||||
}) async {
|
||||
state = state.copyWith(isLoading: true);
|
||||
|
||||
try {
|
||||
final scene = await _dialogueService.loadScene(scenarioId, levelId);
|
||||
final character = await _dialogueService.loadCharacter(scene.characterId);
|
||||
final task = await _dialogueService.loadTask(levelId);
|
||||
final vocabulary = await _dialogueService.loadRequiredVocabulary(levelId);
|
||||
|
||||
// 載入開場對話
|
||||
final openingDialogue = await _dialogueService.getOpeningDialogue(
|
||||
scenarioId,
|
||||
levelId,
|
||||
);
|
||||
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
currentScene: scene,
|
||||
currentCharacter: character,
|
||||
currentTask: task,
|
||||
requiredVocabulary: vocabulary,
|
||||
currentDialogue: openingDialogue,
|
||||
scenarioId: scenarioId,
|
||||
levelId: levelId,
|
||||
isTimeChallenge: isTimeChallenge,
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 發送用戶回覆
|
||||
Future<void> sendReply(String replyText) async {
|
||||
if (replyText.trim().isEmpty) return;
|
||||
|
||||
state = state.copyWith(isProcessing: true);
|
||||
|
||||
try {
|
||||
// 創建用戶回覆
|
||||
final userReply = DialogueMessage(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
content: replyText,
|
||||
isUser: true,
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
|
||||
// 分析回覆
|
||||
final analysis = await _dialogueService.analyzeReply(
|
||||
scenarioId: state.scenarioId!,
|
||||
levelId: state.levelId!,
|
||||
replyText: replyText,
|
||||
requiredVocabulary: state.requiredVocabulary,
|
||||
currentTask: state.currentTask,
|
||||
);
|
||||
|
||||
// 獲取AI回應
|
||||
final aiResponse = await _dialogueService.getAIResponse(
|
||||
scenarioId: state.scenarioId!,
|
||||
levelId: state.levelId!,
|
||||
userReply: replyText,
|
||||
analysis: analysis,
|
||||
);
|
||||
|
||||
// 更新使用的詞彙
|
||||
final newUsedVocabulary = Set<String>.from(state.usedVocabulary);
|
||||
newUsedVocabulary.addAll(analysis.usedVocabulary);
|
||||
|
||||
// 更新任務進度
|
||||
DialogueTask? updatedTask = state.currentTask;
|
||||
if (analysis.taskProgress != null && updatedTask != null) {
|
||||
updatedTask = updatedTask.copyWith(
|
||||
progress: analysis.taskProgress!,
|
||||
isCompleted: analysis.taskProgress! >= 1.0,
|
||||
);
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
isProcessing: false,
|
||||
lastUserReply: userReply,
|
||||
currentDialogue: aiResponse,
|
||||
currentTask: updatedTask,
|
||||
usedVocabulary: newUsedVocabulary,
|
||||
lastAnalysis: analysis,
|
||||
conversationHistory: [...state.conversationHistory, userReply, aiResponse],
|
||||
);
|
||||
|
||||
// 檢查對話是否完成
|
||||
if (analysis.isDialogueComplete || (updatedTask?.isCompleted ?? false)) {
|
||||
_completeDialogue();
|
||||
}
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isProcessing: false,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 顯示回覆輔助
|
||||
Future<void> showReplyAssistance() async {
|
||||
if (state.diamonds < 30) return;
|
||||
|
||||
state = state.copyWith(isProcessing: true);
|
||||
|
||||
try {
|
||||
final suggestions = await _dialogueService.getReplyAssistance(
|
||||
scenarioId: state.scenarioId!,
|
||||
levelId: state.levelId!,
|
||||
currentDialogue: state.currentDialogue?.content ?? '',
|
||||
currentTask: state.currentTask,
|
||||
);
|
||||
|
||||
state = state.copyWith(
|
||||
isProcessing: false,
|
||||
showReplyAssistance: true,
|
||||
replySuggestions: suggestions,
|
||||
diamonds: state.diamonds - 30, // 扣除鑽石
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isProcessing: false,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 隱藏回覆輔助
|
||||
void hideReplyAssistance() {
|
||||
state = state.copyWith(
|
||||
showReplyAssistance: false,
|
||||
replySuggestions: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// 使用時光卷道具
|
||||
Future<void> useTimeWarpCard() async {
|
||||
// TODO: 實現時光卷功能
|
||||
}
|
||||
|
||||
/// 完成對話
|
||||
void _completeDialogue() {
|
||||
final finalScore = _calculateFinalScore();
|
||||
|
||||
state = state.copyWith(
|
||||
isCompleted: true,
|
||||
finalScore: finalScore,
|
||||
);
|
||||
}
|
||||
|
||||
/// 計算最終得分
|
||||
DialogueScore _calculateFinalScore() {
|
||||
// 計算三維度得分
|
||||
double grammarScore = 0.0;
|
||||
double semanticsScore = 0.0;
|
||||
double fluencyScore = 0.0;
|
||||
int totalReplies = 0;
|
||||
|
||||
for (final analysis in state.analysisHistory) {
|
||||
grammarScore += analysis.grammarScore;
|
||||
semanticsScore += analysis.semanticsScore;
|
||||
fluencyScore += analysis.fluencyScore;
|
||||
totalReplies++;
|
||||
}
|
||||
|
||||
if (totalReplies > 0) {
|
||||
grammarScore /= totalReplies;
|
||||
semanticsScore /= totalReplies;
|
||||
fluencyScore /= totalReplies;
|
||||
}
|
||||
|
||||
// 計算任務完成度獎勵
|
||||
double taskBonus = 0.0;
|
||||
if (state.currentTask?.isCompleted ?? false) {
|
||||
taskBonus = 20.0;
|
||||
}
|
||||
|
||||
// 計算詞彙使用獎勵
|
||||
double vocabularyBonus = 0.0;
|
||||
if (state.requiredVocabulary.isNotEmpty) {
|
||||
vocabularyBonus = (state.usedVocabulary.length / state.requiredVocabulary.length) * 10.0;
|
||||
}
|
||||
|
||||
// 計算時間獎勵(限時挑戰)
|
||||
double timeBonus = 0.0;
|
||||
if (state.isTimeChallenge) {
|
||||
// TODO: 根據剩餘時間計算獎勵
|
||||
timeBonus = 5.0;
|
||||
}
|
||||
|
||||
final totalScore = grammarScore + semanticsScore + fluencyScore + taskBonus + vocabularyBonus + timeBonus;
|
||||
|
||||
return DialogueScore(
|
||||
grammarScore: grammarScore,
|
||||
semanticsScore: semanticsScore,
|
||||
fluencyScore: fluencyScore,
|
||||
taskBonus: taskBonus,
|
||||
vocabularyBonus: vocabularyBonus,
|
||||
timeBonus: timeBonus,
|
||||
totalScore: totalScore,
|
||||
starRating: _calculateStarRating(totalScore),
|
||||
);
|
||||
}
|
||||
|
||||
/// 計算星級評價
|
||||
int _calculateStarRating(double totalScore) {
|
||||
if (totalScore >= 90) return 3;
|
||||
if (totalScore >= 70) return 2;
|
||||
if (totalScore >= 50) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// 重置對話狀態
|
||||
void reset() {
|
||||
state = DialogueState.initial();
|
||||
}
|
||||
}
|
||||
|
||||
/// 對話狀態
|
||||
class DialogueState {
|
||||
final bool isLoading;
|
||||
final bool isProcessing;
|
||||
final bool isCompleted;
|
||||
final String? error;
|
||||
|
||||
// 場景信息
|
||||
final String? scenarioId;
|
||||
final String? levelId;
|
||||
final bool isTimeChallenge;
|
||||
final DialogueScene? currentScene;
|
||||
final DialogueCharacter? currentCharacter;
|
||||
|
||||
// 對話內容
|
||||
final DialogueMessage? currentDialogue;
|
||||
final DialogueMessage? lastUserReply;
|
||||
final List<DialogueMessage> conversationHistory;
|
||||
|
||||
// 任務和詞彙
|
||||
final DialogueTask? currentTask;
|
||||
final List<String> requiredVocabulary;
|
||||
final Set<String> usedVocabulary;
|
||||
|
||||
// AI分析
|
||||
final DialogueAnalysis? lastAnalysis;
|
||||
final List<DialogueAnalysis> analysisHistory;
|
||||
|
||||
// 回覆輔助
|
||||
final bool showReplyAssistance;
|
||||
final List<String> replySuggestions;
|
||||
|
||||
// 資源
|
||||
final int lifePoints;
|
||||
final int diamonds;
|
||||
|
||||
// 設置
|
||||
final String currentLanguage;
|
||||
|
||||
// 最終結果
|
||||
final DialogueScore? finalScore;
|
||||
|
||||
DialogueState({
|
||||
required this.isLoading,
|
||||
required this.isProcessing,
|
||||
required this.isCompleted,
|
||||
this.error,
|
||||
this.scenarioId,
|
||||
this.levelId,
|
||||
required this.isTimeChallenge,
|
||||
this.currentScene,
|
||||
this.currentCharacter,
|
||||
this.currentDialogue,
|
||||
this.lastUserReply,
|
||||
required this.conversationHistory,
|
||||
this.currentTask,
|
||||
required this.requiredVocabulary,
|
||||
required this.usedVocabulary,
|
||||
this.lastAnalysis,
|
||||
required this.analysisHistory,
|
||||
required this.showReplyAssistance,
|
||||
required this.replySuggestions,
|
||||
required this.lifePoints,
|
||||
required this.diamonds,
|
||||
required this.currentLanguage,
|
||||
this.finalScore,
|
||||
});
|
||||
|
||||
factory DialogueState.initial() {
|
||||
return DialogueState(
|
||||
isLoading: false,
|
||||
isProcessing: false,
|
||||
isCompleted: false,
|
||||
isTimeChallenge: false,
|
||||
conversationHistory: [],
|
||||
requiredVocabulary: [],
|
||||
usedVocabulary: {},
|
||||
analysisHistory: [],
|
||||
showReplyAssistance: false,
|
||||
replySuggestions: [],
|
||||
lifePoints: 5,
|
||||
diamonds: 100,
|
||||
currentLanguage: 'zh-TW',
|
||||
);
|
||||
}
|
||||
|
||||
DialogueState copyWith({
|
||||
bool? isLoading,
|
||||
bool? isProcessing,
|
||||
bool? isCompleted,
|
||||
String? error,
|
||||
String? scenarioId,
|
||||
String? levelId,
|
||||
bool? isTimeChallenge,
|
||||
DialogueScene? currentScene,
|
||||
DialogueCharacter? currentCharacter,
|
||||
DialogueMessage? currentDialogue,
|
||||
DialogueMessage? lastUserReply,
|
||||
List<DialogueMessage>? conversationHistory,
|
||||
DialogueTask? currentTask,
|
||||
List<String>? requiredVocabulary,
|
||||
Set<String>? usedVocabulary,
|
||||
DialogueAnalysis? lastAnalysis,
|
||||
List<DialogueAnalysis>? analysisHistory,
|
||||
bool? showReplyAssistance,
|
||||
List<String>? replySuggestions,
|
||||
int? lifePoints,
|
||||
int? diamonds,
|
||||
String? currentLanguage,
|
||||
DialogueScore? finalScore,
|
||||
}) {
|
||||
return DialogueState(
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
isProcessing: isProcessing ?? this.isProcessing,
|
||||
isCompleted: isCompleted ?? this.isCompleted,
|
||||
error: error ?? this.error,
|
||||
scenarioId: scenarioId ?? this.scenarioId,
|
||||
levelId: levelId ?? this.levelId,
|
||||
isTimeChallenge: isTimeChallenge ?? this.isTimeChallenge,
|
||||
currentScene: currentScene ?? this.currentScene,
|
||||
currentCharacter: currentCharacter ?? this.currentCharacter,
|
||||
currentDialogue: currentDialogue ?? this.currentDialogue,
|
||||
lastUserReply: lastUserReply ?? this.lastUserReply,
|
||||
conversationHistory: conversationHistory ?? this.conversationHistory,
|
||||
currentTask: currentTask ?? this.currentTask,
|
||||
requiredVocabulary: requiredVocabulary ?? this.requiredVocabulary,
|
||||
usedVocabulary: usedVocabulary ?? this.usedVocabulary,
|
||||
lastAnalysis: lastAnalysis ?? this.lastAnalysis,
|
||||
analysisHistory: analysisHistory ?? List.from(this.analysisHistory),
|
||||
showReplyAssistance: showReplyAssistance ?? this.showReplyAssistance,
|
||||
replySuggestions: replySuggestions ?? this.replySuggestions,
|
||||
lifePoints: lifePoints ?? this.lifePoints,
|
||||
diamonds: diamonds ?? this.diamonds,
|
||||
currentLanguage: currentLanguage ?? this.currentLanguage,
|
||||
finalScore: finalScore ?? this.finalScore,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'DialogueState(loading: $isLoading, processing: $isProcessing, completed: $isCompleted, scenarioId: $scenarioId, levelId: $levelId)';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,656 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
import '../../../shared/widgets/voice_input_button.dart';
|
||||
import '../providers/dialogue_provider.dart';
|
||||
import '../widgets/dialogue_background.dart';
|
||||
import '../widgets/character_avatar.dart';
|
||||
import '../widgets/dialogue_bubble.dart';
|
||||
import '../widgets/task_display_panel.dart';
|
||||
import '../widgets/vocabulary_panel.dart';
|
||||
import '../widgets/reply_assistance_panel.dart';
|
||||
|
||||
/// 情境對話主界面
|
||||
///
|
||||
/// 實現完整的AI對話功能,包括:
|
||||
/// - 沉浸式場景背景
|
||||
/// - 角色對話展示
|
||||
/// - 語音和文字輸入
|
||||
/// - 任務進度追蹤
|
||||
/// - 指定詞彙提示
|
||||
/// - 回覆輔助功能
|
||||
/// - 限時挑戰模式
|
||||
class DialogueMainScreen extends ConsumerStatefulWidget {
|
||||
/// 場景ID
|
||||
final String scenarioId;
|
||||
|
||||
/// 關卡ID
|
||||
final String levelId;
|
||||
|
||||
/// 是否為限時挑戰模式
|
||||
final bool isTimeChallenge;
|
||||
|
||||
const DialogueMainScreen({
|
||||
super.key,
|
||||
required this.scenarioId,
|
||||
required this.levelId,
|
||||
this.isTimeChallenge = false,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<DialogueMainScreen> createState() => _DialogueMainScreenState();
|
||||
}
|
||||
|
||||
class _DialogueMainScreenState extends ConsumerState<DialogueMainScreen>
|
||||
with TickerProviderStateMixin {
|
||||
final TextEditingController _textController = TextEditingController();
|
||||
final FocusNode _textFocusNode = FocusNode();
|
||||
late AnimationController _timerController;
|
||||
late Animation<double> _timerAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// 限時挑戰計時器動畫
|
||||
_timerController = AnimationController(
|
||||
duration: const Duration(seconds: 300), // 300秒 = 5分鐘
|
||||
vsync: this,
|
||||
);
|
||||
_timerAnimation = Tween<double>(
|
||||
begin: 1.0,
|
||||
end: 0.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _timerController,
|
||||
curve: Curves.linear,
|
||||
));
|
||||
|
||||
// 如果是限時挑戰,開始計時
|
||||
if (widget.isTimeChallenge) {
|
||||
_timerController.forward();
|
||||
_timerController.addStatusListener(_onTimerComplete);
|
||||
}
|
||||
|
||||
// 初始化對話
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.read(dialogueProvider.notifier).initializeDialogue(
|
||||
scenarioId: widget.scenarioId,
|
||||
levelId: widget.levelId,
|
||||
isTimeChallenge: widget.isTimeChallenge,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_textController.dispose();
|
||||
_textFocusNode.dispose();
|
||||
_timerController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 計時器完成處理
|
||||
void _onTimerComplete(AnimationStatus status) {
|
||||
if (status == AnimationStatus.completed) {
|
||||
_showTimeUpDialog();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final dialogueState = ref.watch(dialogueProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
// 背景場景
|
||||
DialogueBackground(
|
||||
scenarioId: widget.scenarioId,
|
||||
backgroundUrl: dialogueState.currentScene?.backgroundImageUrl,
|
||||
),
|
||||
|
||||
// 主要內容
|
||||
Column(
|
||||
children: [
|
||||
// 頂部工具列
|
||||
_buildTopBar(dialogueState),
|
||||
|
||||
// 對話內容區域
|
||||
Expanded(
|
||||
child: _buildDialogueContent(dialogueState),
|
||||
),
|
||||
|
||||
// 底部輸入區域
|
||||
_buildInputArea(dialogueState),
|
||||
],
|
||||
),
|
||||
|
||||
// 任務顯示面板
|
||||
if (dialogueState.currentTask != null)
|
||||
Positioned(
|
||||
top: 80.h,
|
||||
right: 16.w,
|
||||
child: TaskDisplayPanel(
|
||||
task: dialogueState.currentTask!,
|
||||
),
|
||||
),
|
||||
|
||||
// 指定詞彙面板
|
||||
if (dialogueState.requiredVocabulary.isNotEmpty)
|
||||
Positioned(
|
||||
top: 80.h,
|
||||
left: 16.w,
|
||||
child: VocabularyPanel(
|
||||
vocabularies: dialogueState.requiredVocabulary,
|
||||
usedVocabularies: dialogueState.usedVocabulary,
|
||||
),
|
||||
),
|
||||
|
||||
// 回覆輔助面板
|
||||
if (dialogueState.showReplyAssistance)
|
||||
Positioned.fill(
|
||||
child: ReplyAssistancePanel(
|
||||
suggestions: dialogueState.replySuggestions,
|
||||
onSelectSuggestion: _onSelectSuggestion,
|
||||
onClose: _closeReplyAssistance,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 頂部工具列
|
||||
Widget _buildTopBar(DialogueState state) {
|
||||
return Container(
|
||||
height: 60.h,
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.w),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.black.withOpacity(0.8),
|
||||
Colors.transparent,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 返回按鈕
|
||||
IconButton(
|
||||
onPressed: _showExitConfirmation,
|
||||
icon: Icon(
|
||||
Icons.arrow_back_ios,
|
||||
color: Colors.white,
|
||||
size: 20.sp,
|
||||
),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 限時挑戰計時器
|
||||
if (widget.isTimeChallenge)
|
||||
AnimatedBuilder(
|
||||
animation: _timerAnimation,
|
||||
builder: (context, child) {
|
||||
final remainingSeconds = (_timerAnimation.value * 300).round();
|
||||
final minutes = remainingSeconds ~/ 60;
|
||||
final seconds = remainingSeconds % 60;
|
||||
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 12.w,
|
||||
vertical: 6.h,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: remainingSeconds < 60
|
||||
? Colors.red.withOpacity(0.8)
|
||||
: Colors.black.withOpacity(0.6),
|
||||
borderRadius: BorderRadius.circular(16.r),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.timer,
|
||||
color: Colors.white,
|
||||
size: 16.sp,
|
||||
),
|
||||
SizedBox(width: 4.w),
|
||||
Text(
|
||||
'${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 資源顯示
|
||||
Row(
|
||||
children: [
|
||||
// 命條
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.favorite,
|
||||
color: Colors.red,
|
||||
size: 16.sp,
|
||||
),
|
||||
SizedBox(width: 4.w),
|
||||
Text(
|
||||
'${state.lifePoints}',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
SizedBox(width: 16.w),
|
||||
|
||||
// 鑽石
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.diamond,
|
||||
color: Colors.blue,
|
||||
size: 16.sp,
|
||||
),
|
||||
SizedBox(width: 4.w),
|
||||
Text(
|
||||
'${state.diamonds}',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 對話內容區域
|
||||
Widget _buildDialogueContent(DialogueState state) {
|
||||
return Column(
|
||||
children: [
|
||||
// 角色頭像和名稱
|
||||
if (state.currentCharacter != null)
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16.h),
|
||||
child: CharacterAvatar(
|
||||
character: state.currentCharacter!,
|
||||
showDetails: true,
|
||||
),
|
||||
),
|
||||
|
||||
// 對話氣泡
|
||||
Expanded(
|
||||
child: Container(
|
||||
margin: EdgeInsets.symmetric(horizontal: 20.w),
|
||||
child: state.currentDialogue != null
|
||||
? DialogueBubble(
|
||||
dialogue: state.currentDialogue!,
|
||||
isUserReply: false,
|
||||
)
|
||||
: Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 用戶回覆氣泡(如果有的話)
|
||||
if (state.lastUserReply != null)
|
||||
Container(
|
||||
margin: EdgeInsets.symmetric(horizontal: 20.w, vertical: 8.h),
|
||||
child: DialogueBubble(
|
||||
dialogue: state.lastUserReply!,
|
||||
isUserReply: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 底部輸入區域
|
||||
Widget _buildInputArea(DialogueState state) {
|
||||
return Container(
|
||||
padding: EdgeInsets.all(16.w),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [
|
||||
Colors.black.withOpacity(0.9),
|
||||
Colors.transparent,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 功能按鈕行
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
// 角色詳情
|
||||
_buildFunctionButton(
|
||||
icon: Icons.person,
|
||||
label: '角色',
|
||||
onTap: _showCharacterDetails,
|
||||
),
|
||||
|
||||
// 關鍵詞
|
||||
_buildFunctionButton(
|
||||
icon: Icons.key,
|
||||
label: '關鍵詞',
|
||||
onTap: _showKeywords,
|
||||
),
|
||||
|
||||
// 任務提示
|
||||
_buildFunctionButton(
|
||||
icon: Icons.lightbulb,
|
||||
label: '任務',
|
||||
onTap: _showTaskHint,
|
||||
disabled: state.currentTask?.isCompleted ?? true,
|
||||
),
|
||||
|
||||
// 中翻英
|
||||
_buildFunctionButton(
|
||||
icon: Icons.translate,
|
||||
label: '翻譯',
|
||||
onTap: _showTranslation,
|
||||
),
|
||||
|
||||
// 回覆輔助
|
||||
_buildFunctionButton(
|
||||
icon: Icons.help,
|
||||
label: '輔助',
|
||||
onTap: _showReplyAssistance,
|
||||
cost: 30,
|
||||
disabled: state.diamonds < 30,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
SizedBox(height: 16.h),
|
||||
|
||||
// 輸入區域
|
||||
Row(
|
||||
children: [
|
||||
// 文字輸入框
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _textController,
|
||||
focusNode: _textFocusNode,
|
||||
maxLines: 3,
|
||||
minLines: 1,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16.sp,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: '請輸入你的回覆...',
|
||||
hintStyle: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 16.sp,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Colors.black.withOpacity(0.6),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24.r),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 20.w,
|
||||
vertical: 12.h,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(width: 8.w),
|
||||
|
||||
// 語音輸入按鈕
|
||||
VoiceInputButton(
|
||||
size: 48,
|
||||
languageId: state.currentLanguage,
|
||||
onResult: _onVoiceResult,
|
||||
onError: _onVoiceError,
|
||||
),
|
||||
|
||||
SizedBox(width: 8.w),
|
||||
|
||||
// 發送按鈕
|
||||
Container(
|
||||
width: 48.w,
|
||||
height: 48.w,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: _textController.text.trim().isNotEmpty
|
||||
? Theme.of(context).primaryColor
|
||||
: Colors.grey,
|
||||
),
|
||||
child: IconButton(
|
||||
onPressed: _textController.text.trim().isNotEmpty
|
||||
? _sendReply
|
||||
: null,
|
||||
icon: Icon(
|
||||
Icons.send,
|
||||
color: Colors.white,
|
||||
size: 20.sp,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 功能按鈕
|
||||
Widget _buildFunctionButton({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required VoidCallback onTap,
|
||||
int? cost,
|
||||
bool disabled = false,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: disabled ? null : onTap,
|
||||
child: Container(
|
||||
width: 60.w,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 40.w,
|
||||
height: 40.w,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: disabled
|
||||
? Colors.grey.withOpacity(0.3)
|
||||
: Colors.white.withOpacity(0.2),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: Icon(
|
||||
icon,
|
||||
color: disabled ? Colors.grey : Colors.white,
|
||||
size: 20.sp,
|
||||
),
|
||||
),
|
||||
if (cost != null)
|
||||
Positioned(
|
||||
top: -2.h,
|
||||
right: -2.w,
|
||||
child: Container(
|
||||
padding: EdgeInsets.all(2.w),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.blue,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.diamond,
|
||||
color: Colors.white,
|
||||
size: 8.sp,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4.h),
|
||||
Text(
|
||||
cost != null ? '$label($cost)' : label,
|
||||
style: TextStyle(
|
||||
color: disabled ? Colors.grey : Colors.white,
|
||||
fontSize: 12.sp,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 語音識別結果處理
|
||||
void _onVoiceResult(String text) {
|
||||
setState(() {
|
||||
_textController.text = text;
|
||||
});
|
||||
}
|
||||
|
||||
/// 語音識別錯誤處理
|
||||
void _onVoiceError(String error) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('語音識別失敗:$error'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 發送回覆
|
||||
void _sendReply() {
|
||||
final text = _textController.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
|
||||
ref.read(dialogueProvider.notifier).sendReply(text);
|
||||
_textController.clear();
|
||||
}
|
||||
|
||||
/// 選擇建議回覆
|
||||
void _onSelectSuggestion(String suggestion) {
|
||||
setState(() {
|
||||
_textController.text = suggestion;
|
||||
});
|
||||
_closeReplyAssistance();
|
||||
}
|
||||
|
||||
/// 顯示角色詳情
|
||||
void _showCharacterDetails() {
|
||||
// TODO: 導航到角色詳情頁面
|
||||
}
|
||||
|
||||
/// 顯示關鍵詞
|
||||
void _showKeywords() {
|
||||
// TODO: 導航到關鍵詞頁面
|
||||
}
|
||||
|
||||
/// 顯示任務提示
|
||||
void _showTaskHint() {
|
||||
// TODO: 顯示任務提示對話框
|
||||
}
|
||||
|
||||
/// 顯示翻譯
|
||||
void _showTranslation() {
|
||||
// TODO: 顯示翻譯對話框
|
||||
}
|
||||
|
||||
/// 顯示回覆輔助
|
||||
void _showReplyAssistance() {
|
||||
ref.read(dialogueProvider.notifier).showReplyAssistance();
|
||||
}
|
||||
|
||||
/// 關閉回覆輔助
|
||||
void _closeReplyAssistance() {
|
||||
ref.read(dialogueProvider.notifier).hideReplyAssistance();
|
||||
}
|
||||
|
||||
/// 顯示退出確認
|
||||
void _showExitConfirmation() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('確認離開'),
|
||||
content: Text(
|
||||
widget.isTimeChallenge
|
||||
? '離開限時挑戰將無法繼續,確定要離開嗎?'
|
||||
: '確定要離開對話嗎?當前進度將會保存。',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text('確定'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 顯示時間結束對話框
|
||||
void _showTimeUpDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('時間到!'),
|
||||
content: Text('限時挑戰時間已結束,正在計算成績...'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
// TODO: 跳轉到結果頁面
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text('查看結果'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,372 +0,0 @@
|
|||
import 'dart:async';
|
||||
|
||||
import '../models/dialogue_models.dart';
|
||||
|
||||
/// 對話服務
|
||||
///
|
||||
/// 提供完整的對話管理功能,包括:
|
||||
/// - 場景和角色加載
|
||||
/// - AI回應生成
|
||||
/// - 語言分析和評分
|
||||
/// - 任務進度跟踪
|
||||
class DialogueService {
|
||||
/// 加載場景信息
|
||||
Future<DialogueScene> loadScene(String scenarioId, String levelId) async {
|
||||
// 模擬API調用延遲
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
// 返回模擬數據
|
||||
return DialogueScene(
|
||||
id: scenarioId,
|
||||
name: '餐廳用餐',
|
||||
description: '在餐廳與服務員進行日常對話',
|
||||
backgroundImageUrl: 'assets/images/restaurant_bg.jpg',
|
||||
characterId: 'waiter_001',
|
||||
difficultyLevel: 'beginner',
|
||||
tags: ['restaurant', 'ordering', 'daily'],
|
||||
);
|
||||
}
|
||||
|
||||
/// 加載角色信息
|
||||
Future<DialogueCharacter> loadCharacter(String characterId) async {
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
|
||||
return DialogueCharacter(
|
||||
id: characterId,
|
||||
name: '小王',
|
||||
description: '友善的餐廳服務員',
|
||||
avatarUrl: 'assets/images/waiter_avatar.jpg',
|
||||
personality: '友善、耐心、專業',
|
||||
role: '服務員',
|
||||
background: '在餐廳工作了3年,非常熟悉菜單和服務流程',
|
||||
specialities: ['點餐服務', '菜品介紹', '客戶服務'],
|
||||
);
|
||||
}
|
||||
|
||||
/// 加載任務信息
|
||||
Future<DialogueTask> loadTask(String levelId) async {
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
|
||||
return DialogueTask(
|
||||
id: 'task_$levelId',
|
||||
title: '完成點餐',
|
||||
description: '與服務員完成一次完整的點餐對話,包括詢問菜品、下訂單、確認價格',
|
||||
type: DialogueTaskType.conversation,
|
||||
requirements: {
|
||||
'minTurns': 5,
|
||||
'mustUseWords': ['menu', 'order', 'price'],
|
||||
'completionCriteria': ['greeting', 'ordering', 'confirmation'],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 加載必需詞彙
|
||||
Future<List<String>> loadRequiredVocabulary(String levelId) async {
|
||||
await Future.delayed(const Duration(milliseconds: 150));
|
||||
|
||||
return [
|
||||
'menu',
|
||||
'order',
|
||||
'price',
|
||||
'recommendation',
|
||||
'delicious',
|
||||
'bill',
|
||||
];
|
||||
}
|
||||
|
||||
/// 獲取開場對話
|
||||
Future<DialogueMessage> getOpeningDialogue(String scenarioId, String levelId) async {
|
||||
await Future.delayed(const Duration(milliseconds: 400));
|
||||
|
||||
return DialogueMessage(
|
||||
id: 'opening_${DateTime.now().millisecondsSinceEpoch}',
|
||||
content: '歡迎光臨!請問您需要什麼嗎?我可以為您介紹今天的特色菜。',
|
||||
isUser: false,
|
||||
timestamp: DateTime.now(),
|
||||
type: DialogueMessageType.text,
|
||||
);
|
||||
}
|
||||
|
||||
/// 分析用戶回覆
|
||||
Future<DialogueAnalysis> analyzeReply({
|
||||
required String scenarioId,
|
||||
required String levelId,
|
||||
required String replyText,
|
||||
required List<String> requiredVocabulary,
|
||||
DialogueTask? currentTask,
|
||||
}) async {
|
||||
await Future.delayed(const Duration(milliseconds: 800));
|
||||
|
||||
// 模擬AI分析
|
||||
final usedWords = _findUsedVocabulary(replyText, requiredVocabulary);
|
||||
final grammarIssues = _analyzeGrammar(replyText);
|
||||
|
||||
// 計算得分
|
||||
final grammarScore = _calculateGrammarScore(grammarIssues);
|
||||
final semanticsScore = _calculateSemanticsScore(replyText, currentTask);
|
||||
final fluencyScore = _calculateFluencyScore(replyText);
|
||||
|
||||
// 計算任務進度
|
||||
double? taskProgress;
|
||||
if (currentTask != null) {
|
||||
taskProgress = _calculateTaskProgress(replyText, currentTask, usedWords);
|
||||
}
|
||||
|
||||
return DialogueAnalysis(
|
||||
id: 'analysis_${DateTime.now().millisecondsSinceEpoch}',
|
||||
userReply: replyText,
|
||||
timestamp: DateTime.now(),
|
||||
grammarScore: grammarScore,
|
||||
semanticsScore: semanticsScore,
|
||||
fluencyScore: fluencyScore,
|
||||
grammarIssues: grammarIssues,
|
||||
usedVocabulary: usedWords,
|
||||
missedVocabulary: requiredVocabulary.where((word) => !usedWords.contains(word)).toList(),
|
||||
suggestions: _generateSuggestions(replyText, grammarIssues),
|
||||
taskProgress: taskProgress,
|
||||
isDialogueComplete: taskProgress != null && taskProgress >= 1.0,
|
||||
);
|
||||
}
|
||||
|
||||
/// 獲取AI回應
|
||||
Future<DialogueMessage> getAIResponse({
|
||||
required String scenarioId,
|
||||
required String levelId,
|
||||
required String userReply,
|
||||
required DialogueAnalysis analysis,
|
||||
}) async {
|
||||
await Future.delayed(const Duration(milliseconds: 600));
|
||||
|
||||
// 根據用戶回覆生成AI回應
|
||||
String response = _generateAIResponse(userReply, analysis);
|
||||
|
||||
return DialogueMessage(
|
||||
id: 'ai_response_${DateTime.now().millisecondsSinceEpoch}',
|
||||
content: response,
|
||||
isUser: false,
|
||||
timestamp: DateTime.now(),
|
||||
type: DialogueMessageType.text,
|
||||
metadata: {
|
||||
'responseType': 'contextual',
|
||||
'grammarScore': analysis.grammarScore,
|
||||
'semanticsScore': analysis.semanticsScore,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 獲取回覆輔助建議
|
||||
Future<List<String>> getReplyAssistance({
|
||||
required String scenarioId,
|
||||
required String levelId,
|
||||
required String currentDialogue,
|
||||
DialogueTask? currentTask,
|
||||
}) async {
|
||||
await Future.delayed(const Duration(milliseconds: 400));
|
||||
|
||||
// 根據當前對話內容生成建議回覆
|
||||
return [
|
||||
'可以給我看一下菜單嗎?',
|
||||
'請推薦一些招牌菜。',
|
||||
'這個菜的價格是多少?',
|
||||
'我想要點這個。',
|
||||
'謝謝,我考慮一下。',
|
||||
];
|
||||
}
|
||||
|
||||
/// 查找使用的詞彙
|
||||
List<String> _findUsedVocabulary(String text, List<String> requiredVocabulary) {
|
||||
final usedWords = <String>[];
|
||||
final lowerText = text.toLowerCase();
|
||||
|
||||
for (final word in requiredVocabulary) {
|
||||
if (lowerText.contains(word.toLowerCase())) {
|
||||
usedWords.add(word);
|
||||
}
|
||||
}
|
||||
|
||||
return usedWords;
|
||||
}
|
||||
|
||||
/// 分析語法
|
||||
List<GrammarIssue> _analyzeGrammar(String text) {
|
||||
final issues = <GrammarIssue>[];
|
||||
|
||||
// 簡單的語法檢查模擬
|
||||
if (text.length < 5) {
|
||||
issues.add(GrammarIssue(
|
||||
type: 'length',
|
||||
description: '回覆太短,請提供更完整的句子',
|
||||
originalText: text,
|
||||
suggestedText: '$text(建議擴展內容)',
|
||||
position: 0,
|
||||
length: text.length,
|
||||
severity: GrammarIssueSeverity.minor,
|
||||
));
|
||||
}
|
||||
|
||||
if (!text.endsWith('.') && !text.endsWith('?') && !text.endsWith('!') && !text.endsWith('?')) {
|
||||
issues.add(GrammarIssue(
|
||||
type: 'punctuation',
|
||||
description: '建議在句尾加上標點符號',
|
||||
originalText: text,
|
||||
suggestedText: '$text。',
|
||||
position: text.length,
|
||||
length: 0,
|
||||
severity: GrammarIssueSeverity.minor,
|
||||
));
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
/// 計算語法得分
|
||||
double _calculateGrammarScore(List<GrammarIssue> issues) {
|
||||
if (issues.isEmpty) return 95.0;
|
||||
|
||||
double penalty = 0.0;
|
||||
for (final issue in issues) {
|
||||
switch (issue.severity) {
|
||||
case GrammarIssueSeverity.critical:
|
||||
penalty += 20.0;
|
||||
break;
|
||||
case GrammarIssueSeverity.major:
|
||||
penalty += 15.0;
|
||||
break;
|
||||
case GrammarIssueSeverity.moderate:
|
||||
penalty += 10.0;
|
||||
break;
|
||||
case GrammarIssueSeverity.minor:
|
||||
penalty += 5.0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (100.0 - penalty).clamp(0.0, 100.0);
|
||||
}
|
||||
|
||||
/// 計算語意得分
|
||||
double _calculateSemanticsScore(String text, DialogueTask? task) {
|
||||
// 基礎語意得分
|
||||
double score = 75.0;
|
||||
|
||||
// 根據文字長度和內容豐富度調整
|
||||
if (text.length > 20) score += 10.0;
|
||||
if (text.length > 50) score += 5.0;
|
||||
|
||||
// 根據任務相關性調整
|
||||
if (task != null) {
|
||||
final requirements = task.requirements['mustUseWords'] as List<dynamic>?;
|
||||
if (requirements != null) {
|
||||
final requiredWords = requirements.cast<String>();
|
||||
final usedCount = requiredWords.where((word) => text.toLowerCase().contains(word.toLowerCase())).length;
|
||||
score += (usedCount / requiredWords.length) * 20.0;
|
||||
}
|
||||
}
|
||||
|
||||
return score.clamp(0.0, 100.0);
|
||||
}
|
||||
|
||||
/// 計算流暢度得分
|
||||
double _calculateFluencyScore(String text) {
|
||||
// 基礎流暢度得分
|
||||
double score = 80.0;
|
||||
|
||||
// 根據句子結構調整
|
||||
if (text.contains(',') || text.contains(',')) score += 5.0;
|
||||
if (text.split(' ').length > 5 || text.length > 15) score += 10.0;
|
||||
|
||||
// 檢查是否有重複詞語
|
||||
final words = text.split(RegExp(r'\s+'));
|
||||
final uniqueWords = words.toSet();
|
||||
if (words.length != uniqueWords.length) score -= 5.0;
|
||||
|
||||
return score.clamp(0.0, 100.0);
|
||||
}
|
||||
|
||||
/// 計算任務進度
|
||||
double _calculateTaskProgress(String text, DialogueTask task, List<String> usedWords) {
|
||||
double progress = 0.0;
|
||||
final requirements = task.requirements;
|
||||
|
||||
// 檢查必需詞彙
|
||||
final mustUseWords = requirements['mustUseWords'] as List<dynamic>?;
|
||||
if (mustUseWords != null) {
|
||||
final requiredWords = mustUseWords.cast<String>();
|
||||
final usedRequiredWords = requiredWords.where((word) => usedWords.contains(word)).length;
|
||||
progress += (usedRequiredWords / requiredWords.length) * 0.5;
|
||||
}
|
||||
|
||||
// 檢查完成標準
|
||||
final completionCriteria = requirements['completionCriteria'] as List<dynamic>?;
|
||||
if (completionCriteria != null) {
|
||||
final criteria = completionCriteria.cast<String>();
|
||||
int metCriteria = 0;
|
||||
|
||||
for (final criterion in criteria) {
|
||||
if (_checkCriterion(text, criterion)) {
|
||||
metCriteria++;
|
||||
}
|
||||
}
|
||||
|
||||
progress += (metCriteria / criteria.length) * 0.5;
|
||||
}
|
||||
|
||||
return progress.clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
/// 檢查完成標準
|
||||
bool _checkCriterion(String text, String criterion) {
|
||||
final lowerText = text.toLowerCase();
|
||||
|
||||
switch (criterion) {
|
||||
case 'greeting':
|
||||
return lowerText.contains('hello') || lowerText.contains('hi') ||
|
||||
lowerText.contains('你好') || lowerText.contains('哈囉');
|
||||
case 'ordering':
|
||||
return lowerText.contains('order') || lowerText.contains('want') ||
|
||||
lowerText.contains('點') || lowerText.contains('要');
|
||||
case 'confirmation':
|
||||
return lowerText.contains('confirm') || lowerText.contains('yes') ||
|
||||
lowerText.contains('ok') || lowerText.contains('確認') ||
|
||||
lowerText.contains('好的');
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成建議
|
||||
List<String> _generateSuggestions(String text, List<GrammarIssue> issues) {
|
||||
final suggestions = <String>[];
|
||||
|
||||
for (final issue in issues) {
|
||||
suggestions.add('${issue.description}: "${issue.suggestedText}"');
|
||||
}
|
||||
|
||||
if (text.length < 10) {
|
||||
suggestions.add('試著提供更詳細的回應');
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
/// 生成AI回應
|
||||
String _generateAIResponse(String userReply, DialogueAnalysis analysis) {
|
||||
final lowerReply = userReply.toLowerCase();
|
||||
|
||||
// 根據用戶回覆內容生成相應回應
|
||||
if (lowerReply.contains('menu') || lowerReply.contains('菜單')) {
|
||||
return '好的,這是我們的菜單。我們今天的特色菜是紅燒肉和宮保雞丁,都很受歡迎呢!';
|
||||
} else if (lowerReply.contains('recommend') || lowerReply.contains('推薦')) {
|
||||
return '我推薦我們的招牌菜紅燒肉,還有今天新鮮的清蒸魚。您比較喜歡什麼口味的呢?';
|
||||
} else if (lowerReply.contains('price') || lowerReply.contains('多少錢') || lowerReply.contains('價格')) {
|
||||
return '紅燒肉是28元,清蒸魚是35元。這些都是我們的人氣菜品,分量也很足。';
|
||||
} else if (lowerReply.contains('order') || lowerReply.contains('點') || lowerReply.contains('要')) {
|
||||
return '好的,已經為您記下了。還需要什麼其他的嗎?飲料或者湯品?';
|
||||
} else if (lowerReply.contains('thank') || lowerReply.contains('謝謝')) {
|
||||
return '不客氣!如果還有什麼需要,請隨時告訴我。';
|
||||
} else {
|
||||
// 預設回應
|
||||
return '我明白了。還有什麼我可以為您服務的嗎?';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
|
||||
import '../models/dialogue_models.dart';
|
||||
|
||||
/// 角色頭像組件
|
||||
class CharacterAvatar extends StatelessWidget {
|
||||
final DialogueCharacter character;
|
||||
final bool showDetails;
|
||||
final double size;
|
||||
|
||||
const CharacterAvatar({
|
||||
super.key,
|
||||
required this.character,
|
||||
this.showDetails = false,
|
||||
this.size = 80.0,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 頭像
|
||||
Container(
|
||||
width: size.w,
|
||||
height: size.w,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Theme.of(context).primaryColor,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: ClipOval(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: character.avatarUrl,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Container(
|
||||
color: Colors.grey[300],
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
size: size.w * 0.5,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
color: Colors.grey[300],
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
size: size.w * 0.5,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (showDetails) ...[
|
||||
SizedBox(height: 8.h),
|
||||
|
||||
// 角色名稱
|
||||
Text(
|
||||
character.name,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 4.h),
|
||||
|
||||
// 角色職業
|
||||
Text(
|
||||
character.role,
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 12.sp,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
|
||||
/// 對話背景組件
|
||||
class DialogueBackground extends StatelessWidget {
|
||||
final String scenarioId;
|
||||
final String? backgroundUrl;
|
||||
|
||||
const DialogueBackground({
|
||||
super.key,
|
||||
required this.scenarioId,
|
||||
this.backgroundUrl,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// 背景圖片
|
||||
if (backgroundUrl != null)
|
||||
CachedNetworkImage(
|
||||
imageUrl: backgroundUrl!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Container(
|
||||
color: Colors.grey[800],
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
color: Colors.grey[800],
|
||||
child: Icon(
|
||||
Icons.image_not_supported,
|
||||
color: Colors.grey[600],
|
||||
size: 64,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.blue[900]!,
|
||||
Colors.purple[900]!,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 漸層覆蓋層
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.black.withOpacity(0.3),
|
||||
Colors.transparent,
|
||||
Colors.black.withOpacity(0.7),
|
||||
],
|
||||
stops: const [0.0, 0.5, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
import '../models/dialogue_models.dart';
|
||||
|
||||
/// 對話氣泡組件
|
||||
class DialogueBubble extends StatelessWidget {
|
||||
final DialogueMessage dialogue;
|
||||
final bool isUserReply;
|
||||
|
||||
const DialogueBubble({
|
||||
super.key,
|
||||
required this.dialogue,
|
||||
required this.isUserReply,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Align(
|
||||
alignment: isUserReply ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.75,
|
||||
),
|
||||
margin: EdgeInsets.symmetric(vertical: 8.h),
|
||||
padding: EdgeInsets.all(16.w),
|
||||
decoration: BoxDecoration(
|
||||
color: isUserReply
|
||||
? Theme.of(context).primaryColor
|
||||
: Colors.white.withOpacity(0.9),
|
||||
borderRadius: BorderRadius.circular(20.r),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
dialogue.content,
|
||||
style: TextStyle(
|
||||
color: isUserReply ? Colors.white : Colors.black87,
|
||||
fontSize: 16.sp,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
/// 回覆輔助面板
|
||||
class ReplyAssistancePanel extends StatelessWidget {
|
||||
final List<String> suggestions;
|
||||
final Function(String) onSelectSuggestion;
|
||||
final VoidCallback onClose;
|
||||
|
||||
const ReplyAssistancePanel({
|
||||
super.key,
|
||||
required this.suggestions,
|
||||
required this.onSelectSuggestion,
|
||||
required this.onClose,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 320.w,
|
||||
height: 400.h,
|
||||
margin: EdgeInsets.all(20.w),
|
||||
padding: EdgeInsets.all(20.w),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16.r),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'回覆建議',
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: onClose,
|
||||
icon: Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
SizedBox(height: 16.h),
|
||||
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: suggestions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final suggestion = suggestions[index];
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: 8.h),
|
||||
child: GestureDetector(
|
||||
onTap: () => onSelectSuggestion(suggestion),
|
||||
child: Container(
|
||||
padding: EdgeInsets.all(12.w),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[100],
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
border: Border.all(color: Colors.grey[300]!),
|
||||
),
|
||||
child: Text(
|
||||
suggestion,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
import '../models/dialogue_models.dart';
|
||||
|
||||
/// 任務顯示面板
|
||||
class TaskDisplayPanel extends StatelessWidget {
|
||||
final DialogueTask task;
|
||||
|
||||
const TaskDisplayPanel({
|
||||
super.key,
|
||||
required this.task,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 200.w,
|
||||
padding: EdgeInsets.all(12.w),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.7),
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
border: Border.all(
|
||||
color: task.isCompleted ? Colors.green : Colors.orange,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
task.isCompleted ? Icons.check_circle : Icons.radio_button_unchecked,
|
||||
color: task.isCompleted ? Colors.green : Colors.orange,
|
||||
size: 16.sp,
|
||||
),
|
||||
SizedBox(width: 6.w),
|
||||
Expanded(
|
||||
child: Text(
|
||||
task.title,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
SizedBox(height: 8.h),
|
||||
|
||||
Text(
|
||||
task.description,
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 12.sp,
|
||||
),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
SizedBox(height: 8.h),
|
||||
|
||||
// 進度條
|
||||
LinearProgressIndicator(
|
||||
value: task.progress,
|
||||
backgroundColor: Colors.grey[600],
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
task.isCompleted ? Colors.green : Colors.orange,
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 4.h),
|
||||
|
||||
Text(
|
||||
'${(task.progress * 100).toInt()}%',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 10.sp,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
/// 詞彙面板
|
||||
class VocabularyPanel extends StatelessWidget {
|
||||
final List<String> vocabularies;
|
||||
final Set<String> usedVocabularies;
|
||||
|
||||
const VocabularyPanel({
|
||||
super.key,
|
||||
required this.vocabularies,
|
||||
required this.usedVocabularies,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 150.w,
|
||||
padding: EdgeInsets.all(12.w),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.7),
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'指定詞彙',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 8.h),
|
||||
|
||||
...vocabularies.map((word) => Padding(
|
||||
padding: EdgeInsets.only(bottom: 4.h),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
usedVocabularies.contains(word) ? Icons.check : Icons.radio_button_unchecked,
|
||||
color: usedVocabularies.contains(word) ? Colors.green : Colors.grey,
|
||||
size: 14.sp,
|
||||
),
|
||||
SizedBox(width: 6.w),
|
||||
Expanded(
|
||||
child: Text(
|
||||
word,
|
||||
style: TextStyle(
|
||||
color: usedVocabularies.contains(word) ? Colors.green : Colors.white70,
|
||||
fontSize: 12.sp,
|
||||
decoration: usedVocabularies.contains(word) ? TextDecoration.lineThrough : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)).toList(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/services/voice_recognition_service.dart';
|
||||
|
||||
/// 語音識別服務提供者
|
||||
final voiceRecognitionServiceProvider = Provider<VoiceRecognitionService>((ref) {
|
||||
return VoiceRecognitionService();
|
||||
});
|
||||
|
||||
/// 語音識別狀態提供者
|
||||
final voiceRecognitionStateProvider = StreamProvider<VoiceRecognitionState>((ref) {
|
||||
final service = ref.watch(voiceRecognitionServiceProvider);
|
||||
return service.stateStream;
|
||||
});
|
||||
|
||||
/// 語音識別結果提供者
|
||||
final voiceRecognitionResultProvider = StreamProvider<VoiceRecognitionResult>((ref) {
|
||||
final service = ref.watch(voiceRecognitionServiceProvider);
|
||||
return service.resultStream;
|
||||
});
|
||||
|
||||
/// 語音音量提供者
|
||||
final voiceSoundLevelProvider = StreamProvider<double>((ref) {
|
||||
final service = ref.watch(voiceRecognitionServiceProvider);
|
||||
return service.soundLevelStream;
|
||||
});
|
||||
|
||||
/// 語音識別控制器提供者
|
||||
final voiceRecognitionControllerProvider = StateNotifierProvider<VoiceRecognitionController, VoiceRecognitionControllerState>((ref) {
|
||||
final service = ref.watch(voiceRecognitionServiceProvider);
|
||||
return VoiceRecognitionController(service);
|
||||
});
|
||||
|
||||
/// 語音識別控制器
|
||||
class VoiceRecognitionController extends StateNotifier<VoiceRecognitionControllerState> {
|
||||
final VoiceRecognitionService _voiceService;
|
||||
|
||||
VoiceRecognitionController(this._voiceService) : super(VoiceRecognitionControllerState.initial()) {
|
||||
_init();
|
||||
}
|
||||
|
||||
/// 初始化
|
||||
Future<void> _init() async {
|
||||
final success = await _voiceService.initialize();
|
||||
state = state.copyWith(
|
||||
isInitialized: success,
|
||||
isAvailable: _voiceService.isAvailable,
|
||||
);
|
||||
}
|
||||
|
||||
/// 開始語音識別
|
||||
Future<void> startListening({
|
||||
String languageId = 'zh-TW',
|
||||
Duration timeout = const Duration(seconds: 30),
|
||||
bool partialResults = true,
|
||||
}) async {
|
||||
if (!state.isInitialized || !state.isAvailable) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = state.copyWith(isProcessing: true);
|
||||
|
||||
final success = await _voiceService.startListening(
|
||||
languageId: languageId,
|
||||
timeout: timeout,
|
||||
partialResults: partialResults,
|
||||
);
|
||||
|
||||
state = state.copyWith(
|
||||
isProcessing: false,
|
||||
isListening: success,
|
||||
currentLanguage: languageId,
|
||||
);
|
||||
}
|
||||
|
||||
/// 停止語音識別
|
||||
Future<void> stopListening() async {
|
||||
if (!state.isListening) return;
|
||||
|
||||
state = state.copyWith(isProcessing: true);
|
||||
await _voiceService.stopListening();
|
||||
state = state.copyWith(
|
||||
isProcessing: false,
|
||||
isListening: false,
|
||||
lastResult: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// 取消語音識別
|
||||
Future<void> cancel() async {
|
||||
if (!state.isListening) return;
|
||||
|
||||
state = state.copyWith(isProcessing: true);
|
||||
await _voiceService.cancel();
|
||||
state = state.copyWith(
|
||||
isProcessing: false,
|
||||
isListening: false,
|
||||
lastResult: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// 更新最後的識別結果
|
||||
void updateLastResult(VoiceRecognitionResult result) {
|
||||
state = state.copyWith(lastResult: result);
|
||||
}
|
||||
|
||||
/// 更新語言
|
||||
void setLanguage(String languageId) {
|
||||
state = state.copyWith(currentLanguage: languageId);
|
||||
}
|
||||
|
||||
/// 重新初始化
|
||||
Future<void> reinitialize() async {
|
||||
state = VoiceRecognitionControllerState.initial();
|
||||
await _init();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_voiceService.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// 語音識別控制器狀態
|
||||
class VoiceRecognitionControllerState {
|
||||
final bool isInitialized;
|
||||
final bool isAvailable;
|
||||
final bool isListening;
|
||||
final bool isProcessing;
|
||||
final String currentLanguage;
|
||||
final VoiceRecognitionResult? lastResult;
|
||||
|
||||
VoiceRecognitionControllerState({
|
||||
required this.isInitialized,
|
||||
required this.isAvailable,
|
||||
required this.isListening,
|
||||
required this.isProcessing,
|
||||
required this.currentLanguage,
|
||||
this.lastResult,
|
||||
});
|
||||
|
||||
factory VoiceRecognitionControllerState.initial() {
|
||||
return VoiceRecognitionControllerState(
|
||||
isInitialized: false,
|
||||
isAvailable: false,
|
||||
isListening: false,
|
||||
isProcessing: false,
|
||||
currentLanguage: 'zh-TW',
|
||||
);
|
||||
}
|
||||
|
||||
VoiceRecognitionControllerState copyWith({
|
||||
bool? isInitialized,
|
||||
bool? isAvailable,
|
||||
bool? isListening,
|
||||
bool? isProcessing,
|
||||
String? currentLanguage,
|
||||
VoiceRecognitionResult? lastResult,
|
||||
}) {
|
||||
return VoiceRecognitionControllerState(
|
||||
isInitialized: isInitialized ?? this.isInitialized,
|
||||
isAvailable: isAvailable ?? this.isAvailable,
|
||||
isListening: isListening ?? this.isListening,
|
||||
isProcessing: isProcessing ?? this.isProcessing,
|
||||
currentLanguage: currentLanguage ?? this.currentLanguage,
|
||||
lastResult: lastResult ?? this.lastResult,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'VoiceRecognitionControllerState(initialized: $isInitialized, available: $isAvailable, listening: $isListening, processing: $isProcessing, language: $currentLanguage)';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,429 +0,0 @@
|
|||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
import '../../core/services/voice_recognition_service.dart';
|
||||
import '../providers/voice_recognition_provider.dart';
|
||||
|
||||
/// AI語音輸入按鈕
|
||||
///
|
||||
/// 提供語音識別功能,支援:
|
||||
/// - 長按開始錄音,鬆開停止錄音
|
||||
/// - 實時音量動畫效果
|
||||
/// - 錯誤狀態提示
|
||||
/// - 多語言支援
|
||||
class VoiceInputButton extends ConsumerStatefulWidget {
|
||||
/// 語音識別結果回調
|
||||
final Function(String text) onResult;
|
||||
|
||||
/// 語音識別錯誤回調
|
||||
final Function(String error)? onError;
|
||||
|
||||
/// 語言ID (zh-TW, zh-CN, en-US, en-GB)
|
||||
final String languageId;
|
||||
|
||||
/// 按鈕大小
|
||||
final double size;
|
||||
|
||||
/// 是否啟用部分結果
|
||||
final bool enablePartialResults;
|
||||
|
||||
/// 監聽超時時間
|
||||
final Duration timeout;
|
||||
|
||||
const VoiceInputButton({
|
||||
super.key,
|
||||
required this.onResult,
|
||||
this.onError,
|
||||
this.languageId = 'zh-TW',
|
||||
this.size = 56.0,
|
||||
this.enablePartialResults = true,
|
||||
this.timeout = const Duration(seconds: 30),
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<VoiceInputButton> createState() => _VoiceInputButtonState();
|
||||
}
|
||||
|
||||
class _VoiceInputButtonState extends ConsumerState<VoiceInputButton>
|
||||
with TickerProviderStateMixin {
|
||||
late AnimationController _pulseController;
|
||||
late AnimationController _scaleController;
|
||||
late Animation<double> _pulseAnimation;
|
||||
late Animation<double> _scaleAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// 脈搏動畫控制器
|
||||
_pulseController = AnimationController(
|
||||
duration: const Duration(milliseconds: 1000),
|
||||
vsync: this,
|
||||
);
|
||||
_pulseAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _pulseController,
|
||||
curve: Curves.easeInOut,
|
||||
));
|
||||
|
||||
// 縮放動畫控制器
|
||||
_scaleController = AnimationController(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
vsync: this,
|
||||
);
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: 1.0,
|
||||
end: 1.1,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _scaleController,
|
||||
curve: Curves.easeInOut,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pulseController.dispose();
|
||||
_scaleController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 監聽語音識別狀態
|
||||
final voiceState = ref.watch(voiceRecognitionStateProvider);
|
||||
final voiceController = ref.watch(voiceRecognitionControllerProvider.notifier);
|
||||
final controllerState = ref.watch(voiceRecognitionControllerProvider);
|
||||
final soundLevel = ref.watch(voiceSoundLevelProvider);
|
||||
|
||||
// 監聽識別結果
|
||||
ref.listen<AsyncValue<VoiceRecognitionResult>>(
|
||||
voiceRecognitionResultProvider,
|
||||
(previous, next) {
|
||||
next.whenData((result) {
|
||||
if (result.isFinal) {
|
||||
widget.onResult(result.recognizedWords);
|
||||
voiceController.updateLastResult(result);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// 監聽狀態變化
|
||||
ref.listen<AsyncValue<VoiceRecognitionState>>(
|
||||
voiceRecognitionStateProvider,
|
||||
(previous, next) {
|
||||
next.whenData((state) {
|
||||
if (state.status == VoiceRecognitionStatus.listening) {
|
||||
_pulseController.repeat(reverse: true);
|
||||
_scaleController.forward();
|
||||
} else {
|
||||
_pulseController.stop();
|
||||
_scaleController.reverse();
|
||||
}
|
||||
|
||||
if (state.hasError) {
|
||||
widget.onError?.call(state.errorMessage ?? '語音識別錯誤');
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: Listenable.merge([_pulseAnimation, _scaleAnimation]),
|
||||
builder: (context, child) {
|
||||
return Container(
|
||||
width: widget.size.w,
|
||||
height: widget.size.w,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
Theme.of(context).primaryColor.withOpacity(0.3),
|
||||
Theme.of(context).primaryColor.withOpacity(0.1),
|
||||
Colors.transparent,
|
||||
],
|
||||
stops: [
|
||||
0.0,
|
||||
_pulseAnimation.value * 0.8,
|
||||
_pulseAnimation.value,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Transform.scale(
|
||||
scale: _scaleAnimation.value,
|
||||
child: GestureDetector(
|
||||
onLongPressStart: (_) => _startListening(),
|
||||
onLongPressEnd: (_) => _stopListening(),
|
||||
onTap: () => _toggleListening(),
|
||||
child: Container(
|
||||
width: (widget.size * 0.8).w,
|
||||
height: (widget.size * 0.8).w,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: _getButtonColor(controllerState),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Theme.of(context).primaryColor.withOpacity(0.3),
|
||||
blurRadius: 8,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// 主要圖標
|
||||
Center(
|
||||
child: Icon(
|
||||
_getButtonIcon(controllerState),
|
||||
color: Colors.white,
|
||||
size: (widget.size * 0.4).w,
|
||||
),
|
||||
),
|
||||
|
||||
// 音量指示器
|
||||
if (controllerState.isListening)
|
||||
_buildSoundLevelIndicator(soundLevel),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 音量指示器
|
||||
Widget _buildSoundLevelIndicator(AsyncValue<double> soundLevelAsync) {
|
||||
return soundLevelAsync.when(
|
||||
data: (level) {
|
||||
return Positioned.fill(
|
||||
child: CustomPaint(
|
||||
painter: SoundLevelPainter(
|
||||
level: level,
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 開始監聽
|
||||
Future<void> _startListening() async {
|
||||
final controller = ref.read(voiceRecognitionControllerProvider.notifier);
|
||||
await controller.startListening(
|
||||
languageId: widget.languageId,
|
||||
timeout: widget.timeout,
|
||||
partialResults: widget.enablePartialResults,
|
||||
);
|
||||
}
|
||||
|
||||
/// 停止監聽
|
||||
Future<void> _stopListening() async {
|
||||
final controller = ref.read(voiceRecognitionControllerProvider.notifier);
|
||||
await controller.stopListening();
|
||||
}
|
||||
|
||||
/// 切換監聽狀態
|
||||
Future<void> _toggleListening() async {
|
||||
final state = ref.read(voiceRecognitionControllerProvider);
|
||||
if (state.isListening) {
|
||||
await _stopListening();
|
||||
} else {
|
||||
await _startListening();
|
||||
}
|
||||
}
|
||||
|
||||
/// 獲取按鈕顏色
|
||||
Color _getButtonColor(VoiceRecognitionControllerState state) {
|
||||
if (!state.isInitialized || !state.isAvailable) {
|
||||
return Colors.grey;
|
||||
}
|
||||
|
||||
if (state.isListening) {
|
||||
return Colors.red;
|
||||
}
|
||||
|
||||
if (state.isProcessing) {
|
||||
return Theme.of(context).primaryColor.withOpacity(0.7);
|
||||
}
|
||||
|
||||
return Theme.of(context).primaryColor;
|
||||
}
|
||||
|
||||
/// 獲取按鈕圖標
|
||||
IconData _getButtonIcon(VoiceRecognitionControllerState state) {
|
||||
if (!state.isInitialized || !state.isAvailable) {
|
||||
return Icons.mic_off;
|
||||
}
|
||||
|
||||
if (state.isListening) {
|
||||
return Icons.stop;
|
||||
}
|
||||
|
||||
if (state.isProcessing) {
|
||||
return Icons.hourglass_empty;
|
||||
}
|
||||
|
||||
return Icons.mic;
|
||||
}
|
||||
}
|
||||
|
||||
/// 音量波形繪製器
|
||||
class SoundLevelPainter extends CustomPainter {
|
||||
final double level;
|
||||
final Color color;
|
||||
|
||||
SoundLevelPainter({
|
||||
required this.level,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2.0;
|
||||
|
||||
final center = Offset(size.width / 2, size.height / 2);
|
||||
final radius = math.min(size.width, size.height) / 2;
|
||||
|
||||
// 繪製音量波紋
|
||||
for (int i = 1; i <= 3; i++) {
|
||||
final waveRadius = radius * 0.6 + (level * 0.3 * radius) * i / 3;
|
||||
final alpha = (1.0 - i / 3) * level;
|
||||
|
||||
paint.color = color.withOpacity(alpha);
|
||||
canvas.drawCircle(center, waveRadius, paint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant SoundLevelPainter oldDelegate) {
|
||||
return oldDelegate.level != level || oldDelegate.color != color;
|
||||
}
|
||||
}
|
||||
|
||||
/// 語音輸入提示對話框
|
||||
class VoiceInputDialog extends ConsumerStatefulWidget {
|
||||
final String languageId;
|
||||
final Function(String text) onResult;
|
||||
final Function(String error)? onError;
|
||||
|
||||
const VoiceInputDialog({
|
||||
super.key,
|
||||
this.languageId = 'zh-TW',
|
||||
required this.onResult,
|
||||
this.onError,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<VoiceInputDialog> createState() => _VoiceInputDialogState();
|
||||
}
|
||||
|
||||
class _VoiceInputDialogState extends ConsumerState<VoiceInputDialog> {
|
||||
String _currentText = '';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 監聽識別結果
|
||||
ref.listen<AsyncValue<VoiceRecognitionResult>>(
|
||||
voiceRecognitionResultProvider,
|
||||
(previous, next) {
|
||||
next.whenData((result) {
|
||||
setState(() {
|
||||
_currentText = result.recognizedWords;
|
||||
});
|
||||
|
||||
if (result.isFinal) {
|
||||
Navigator.of(context).pop();
|
||||
widget.onResult(result.recognizedWords);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(
|
||||
'語音輸入',
|
||||
style: TextStyle(fontSize: 18.sp),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
content: Container(
|
||||
width: 280.w,
|
||||
height: 200.h,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 語音輸入按鈕
|
||||
VoiceInputButton(
|
||||
size: 80,
|
||||
languageId: widget.languageId,
|
||||
onResult: (text) {},
|
||||
onError: widget.onError,
|
||||
),
|
||||
|
||||
SizedBox(height: 20.h),
|
||||
|
||||
// 識別文字顯示
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: 80.h,
|
||||
padding: EdgeInsets.all(12.w),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
_currentText.isEmpty ? '請開始說話...' : _currentText,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: _currentText.isEmpty ? Colors.grey : Colors.black87,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
final controller = ref.read(voiceRecognitionControllerProvider.notifier);
|
||||
controller.cancel();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(
|
||||
'取消',
|
||||
style: TextStyle(fontSize: 14.sp),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
widget.onResult(_currentText);
|
||||
},
|
||||
child: Text(
|
||||
'確定',
|
||||
style: TextStyle(fontSize: 14.sp),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,193 +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 useAuthStore: typeof import('./src/stores/auth')['useAuthStore']
|
||||
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 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 useQuasar: typeof import('quasar')['useQuasar']
|
||||
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 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 useAuthStore: UnwrapRef<typeof import('./src/stores/auth')['useAuthStore']>
|
||||
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 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 useQuasar: UnwrapRef<typeof import('quasar')['useQuasar']>
|
||||
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 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,23 +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']
|
||||
ModalContainer: typeof import('./src/components/ui/ModalContainer.vue')['default']
|
||||
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']
|
||||
ToastContainer: typeof import('./src/components/ui/ToastContainer.vue')['default']
|
||||
}
|
||||
}
|
||||
|
|
@ -1,103 +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 - AI語言學習</title>
|
||||
<meta name="description" content="AI驅動的情境式語言學習應用,透過真實對話場景提升語言能力">
|
||||
<meta name="keywords" content="語言學習,AI,英語,對話,情境學習">
|
||||
<meta name="author" content="Drama Ling Team">
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://dramaling.com/">
|
||||
<meta property="og:title" content="Drama Ling - AI語言學習">
|
||||
<meta property="og:description" content="AI驅動的情境式語言學習應用">
|
||||
<meta property="og:image" content="/og-image.jpg">
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="summary_large_image">
|
||||
<meta property="twitter:url" content="https://dramaling.com/">
|
||||
<meta property="twitter:title" content="Drama Ling - AI語言學習">
|
||||
<meta property="twitter:description" content="AI驅動的情境式語言學習應用">
|
||||
<meta property="twitter:image" content="/og-image.jpg">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="icon" type="image/png" href="/favicon.png">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Theme Color -->
|
||||
<meta name="theme-color" content="#00E5CC">
|
||||
<meta name="msapplication-TileColor" content="#00E5CC">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
|
||||
<!-- 載入畫面 -->
|
||||
<style>
|
||||
#loading-screen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #2C3E50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.loading-logo {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border: 4px solid #34495E;
|
||||
border-top: 4px solid #00E5CC;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
color: #B8BCC8;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="loading-screen">
|
||||
<div class="loading-logo"></div>
|
||||
<div class="loading-text">Drama Ling 載入中...</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 移除載入畫面
|
||||
window.addEventListener('load', function() {
|
||||
setTimeout(function() {
|
||||
const loadingScreen = document.getElementById('loading-screen');
|
||||
if (loadingScreen) {
|
||||
loadingScreen.style.opacity = '0';
|
||||
loadingScreen.style.transition = 'opacity 0.5s ease';
|
||||
setTimeout(function() {
|
||||
loadingScreen.remove();
|
||||
}, 500);
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,64 +0,0 @@
|
|||
{
|
||||
"name": "dramaling-web",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"build": "vue-tsc --noEmit && 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": {
|
||||
"vue": "^3.4.21",
|
||||
"vue-router": "^4.3.0",
|
||||
"pinia": "^2.1.7",
|
||||
"pinia-plugin-persistedstate": "^3.2.1",
|
||||
"quasar": "^2.16.0",
|
||||
"@quasar/extras": "^1.16.4",
|
||||
"axios": "^1.6.8",
|
||||
"vee-validate": "^4.12.6",
|
||||
"yup": "^1.4.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"dayjs": "^1.11.10",
|
||||
"dompurify": "^3.0.11",
|
||||
"@vueuse/core": "^10.9.0",
|
||||
"workbox-window": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"vite": "^5.2.0",
|
||||
"vue-tsc": "^2.0.6",
|
||||
"typescript": "^5.4.0",
|
||||
"@types/node": "^20.12.7",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"vitest": "^1.5.0",
|
||||
"@vue/test-utils": "^2.4.5",
|
||||
"happy-dom": "^14.7.1",
|
||||
"@vitest/coverage-v8": "^1.5.0",
|
||||
"@vitest/ui": "^1.5.0",
|
||||
"cypress": "^13.7.2",
|
||||
"eslint": "^9.1.1",
|
||||
"@vue/eslint-config-typescript": "^13.0.0",
|
||||
"@vue/eslint-config-prettier": "^9.0.0",
|
||||
"prettier": "^3.2.5",
|
||||
"stylelint": "^16.4.0",
|
||||
"stylelint-config-standard-scss": "^13.1.0",
|
||||
"stylelint-config-standard-vue": "^1.0.0",
|
||||
"husky": "^9.0.11",
|
||||
"lint-staged": "^15.2.2",
|
||||
"unplugin-vue-components": "^0.27.0",
|
||||
"unplugin-auto-import": "^0.17.5",
|
||||
"vite-plugin-pwa": "^0.20.0",
|
||||
"@quasar/vite-plugin": "^1.6.0",
|
||||
"sass": "^1.77.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
<template>
|
||||
<div id="app">
|
||||
<router-view />
|
||||
|
||||
<!-- 全局通知系統 -->
|
||||
<ToastContainer />
|
||||
|
||||
<!-- 全局彈窗系統 -->
|
||||
<ModalContainer />
|
||||
|
||||
<!-- 全局載入指示器 -->
|
||||
<q-ajax-bar
|
||||
position="top"
|
||||
color="primary-teal"
|
||||
size="4px"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
import ToastContainer from '@/components/ui/ToastContainer.vue'
|
||||
import ModalContainer from '@/components/ui/ModalContainer.vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const uiStore = useUIStore()
|
||||
|
||||
onMounted(async () => {
|
||||
// 初始化UI系統
|
||||
uiStore.initializeUI()
|
||||
|
||||
// 初始化認證狀態
|
||||
await authStore.initialize()
|
||||
|
||||
// 設定全局鍵盤事件監聽
|
||||
document.addEventListener('keydown', handleGlobalKeydown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 清理資源
|
||||
uiStore.cleanup()
|
||||
document.removeEventListener('keydown', handleGlobalKeydown)
|
||||
})
|
||||
|
||||
const handleGlobalKeydown = (event: KeyboardEvent) => {
|
||||
// ESC 鍵關閉彈窗和選單
|
||||
if (event.key === 'Escape') {
|
||||
if (uiStore.currentModal) {
|
||||
uiStore.hideModal()
|
||||
}
|
||||
if (uiStore.mobileMenuOpen) {
|
||||
uiStore.closeMobileMenu()
|
||||
}
|
||||
}
|
||||
}
|
||||
</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%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 全局滾動條樣式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(0,0,0, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(0,0,0, 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0,0,0, 0.5);
|
||||
}
|
||||
|
||||
/* 無障礙聚焦樣式 */
|
||||
*:focus-visible {
|
||||
outline: 2px solid #00E5CC;
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* 全局動畫 */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-enter-from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.slide-leave-to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
</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,171 +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-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; }
|
||||
}
|
||||
|
|
@ -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,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,545 +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'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const userStore = useUserStore()
|
||||
const uiStore = useUIStore()
|
||||
|
||||
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: '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: '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: $z-sidebar;
|
||||
|
||||
@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: $z-mobile-nav;
|
||||
|
||||
@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>
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
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'
|
||||
import '@quasar/extras/material-icons-outlined/material-icons-outlined.css'
|
||||
import '@quasar/extras/material-icons-round/material-icons-round.css'
|
||||
|
||||
// 自定義樣式
|
||||
// import './assets/styles/main.scss'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// 配置 Quasar
|
||||
app.use(Quasar, {
|
||||
plugins: {
|
||||
Notify,
|
||||
Loading,
|
||||
Dialog
|
||||
},
|
||||
config: {
|
||||
notify: {
|
||||
position: 'top-right',
|
||||
timeout: 5000
|
||||
},
|
||||
loading: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
||||
spinnerColor: '#00E5CC',
|
||||
messageColor: 'white'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 配置 Pinia
|
||||
app.use(pinia)
|
||||
|
||||
// 配置 Vue Router
|
||||
app.use(router)
|
||||
|
||||
// 全局錯誤處理
|
||||
app.config.errorHandler = (err, instance, info) => {
|
||||
console.error('Vue Error:', err)
|
||||
console.error('Error Info:', info)
|
||||
|
||||
// 在生產環境中可以發送錯誤到監控服務
|
||||
if (import.meta.env.PROD) {
|
||||
// 發送錯誤報告
|
||||
console.error('Production Error:', { err, info })
|
||||
}
|
||||
}
|
||||
|
||||
app.mount('#app')
|
||||
|
|
@ -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,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,286 +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 {
|
||||
// TODO: 實際API調用
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(credentials)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
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,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,302 +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 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,
|
||||
|
||||
// 動作
|
||||
fetchUserProfile,
|
||||
fetchUserProgress,
|
||||
updatePreferences,
|
||||
updateDailyGoal,
|
||||
addExperience,
|
||||
incrementLearningTime,
|
||||
updateStreak,
|
||||
fetchAchievements,
|
||||
unlockAchievement,
|
||||
fetchFriends,
|
||||
clearUserData
|
||||
}
|
||||
}, {
|
||||
persist: {
|
||||
paths: ['preferences']
|
||||
}
|
||||
})
|
||||
|
|
@ -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,112 +0,0 @@
|
|||
export interface User {
|
||||
id: string
|
||||
email: string
|
||||
username: string
|
||||
avatar?: string
|
||||
firstName?: string
|
||||
lastName?: string
|
||||
dateOfBirth?: string
|
||||
phoneNumber?: string
|
||||
country?: string
|
||||
nativeLanguage?: string
|
||||
targetLanguage?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
emailVerified: boolean
|
||||
isActive: boolean
|
||||
subscriptionPlan?: 'free' | 'premium' | 'unlimited'
|
||||
subscriptionExpiry?: string
|
||||
}
|
||||
|
||||
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,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,524 +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>
|
||||
</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 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')
|
||||
}
|
||||
</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;
|
||||
}
|
||||
|
||||
@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,152 +0,0 @@
|
|||
<template>
|
||||
<div class="offline-view">
|
||||
<q-page class="flex flex-center">
|
||||
<div class="text-center">
|
||||
<q-icon name="wifi_off" size="120px" color="grey-5" />
|
||||
<h1 class="text-h4 q-mt-lg q-mb-md">離線狀態</h1>
|
||||
<p class="text-body1 text-grey-7 q-mb-xl">
|
||||
網路連線中斷,您正在離線模式下瀏覽。<br>
|
||||
部分功能可能受到限制。
|
||||
</p>
|
||||
|
||||
<q-card class="offline-features q-mb-xl">
|
||||
<q-card-section>
|
||||
<div class="text-h6 q-mb-md">離線可用功能</div>
|
||||
<q-list>
|
||||
<q-item>
|
||||
<q-item-section avatar>
|
||||
<q-icon name="check_circle" color="positive" />
|
||||
</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="check_circle" color="positive" />
|
||||
</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="cancel" color="negative" />
|
||||
</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="cancel" color="negative" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>AI 輔導功能</q-item-label>
|
||||
<q-item-label caption>需要網路連線</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<div class="connection-status q-mb-lg">
|
||||
<q-spinner v-if="isRetrying" color="primary" size="md" class="q-mr-sm" />
|
||||
<span v-if="isRetrying">嘗試重新連線中...</span>
|
||||
<span v-else class="text-grey-7">檢查您的網路連線</span>
|
||||
</div>
|
||||
|
||||
<div class="action-buttons">
|
||||
<q-btn
|
||||
color="primary"
|
||||
size="lg"
|
||||
label="重新連線"
|
||||
@click="retryConnection"
|
||||
:loading="isRetrying"
|
||||
class="q-mr-md"
|
||||
/>
|
||||
<q-btn
|
||||
color="secondary"
|
||||
size="lg"
|
||||
label="繼續離線使用"
|
||||
@click="continueOffline"
|
||||
outline
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</q-page>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const isRetrying = ref(false)
|
||||
|
||||
const retryConnection = async () => {
|
||||
isRetrying.value = true
|
||||
|
||||
// 模擬連線檢查
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
// 檢查網路狀態
|
||||
if (navigator.onLine) {
|
||||
// 如果已連線,跳轉到首頁
|
||||
router.push({ name: 'home' })
|
||||
} else {
|
||||
isRetrying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const continueOffline = () => {
|
||||
router.push({ name: 'home' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.offline-view {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.offline-features {
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.connection-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.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,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,317 +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>
|
||||
</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 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 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,39 +0,0 @@
|
|||
<template>
|
||||
<div class="vocabulary-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-8">
|
||||
<q-card class="q-mb-md">
|
||||
<q-card-section>
|
||||
<div class="text-h6">今日詞彙挑戰</div>
|
||||
<p class="text-body2 text-grey-7">完成今日的詞彙學習目標</p>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<q-linear-progress :value="0.3" color="primary" class="q-mb-sm" />
|
||||
<div class="text-caption">進度: 3/10 個詞彙</div>
|
||||
</q-card-section>
|
||||
<q-card-actions>
|
||||
<q-btn color="primary" label="開始學習" />
|
||||
<q-btn flat label="複習" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
</q-page>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 詞彙學習組件邏輯
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vocabulary-view {
|
||||
min-height: 100vh;
|
||||
}
|
||||
</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>
|
||||
|
|
@ -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", "vue/ref-macros"],
|
||||
"allowJs": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.vue",
|
||||
"tests/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
|
|
@ -1,136 +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}'],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^https:\/\/api\.dramaling\.com\/.*/i,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'api-cache',
|
||||
expiration: {
|
||||
maxEntries: 50,
|
||||
maxAgeSeconds: 5 * 60
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
manifest: {
|
||||
name: 'Drama Ling - AI語言學習',
|
||||
short_name: 'Drama Ling',
|
||||
description: 'AI驅動的情境式語言學習應用',
|
||||
theme_color: '#00E5CC',
|
||||
background_color: '#2C3E50',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait-primary',
|
||||
scope: '/',
|
||||
start_url: '/',
|
||||
icons: [
|
||||
{
|
||||
src: '/icons/icon-192x192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png'
|
||||
},
|
||||
{
|
||||
src: '/icons/icon-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
],
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
47
dl
47
dl
|
|
@ -18,11 +18,6 @@ show_menu() {
|
|||
echo -e "${BLUE}🎭 Drama Ling 管理工具${NC}"
|
||||
echo "=================================="
|
||||
echo ""
|
||||
echo -e "${PURPLE}🎯 統一任務管理${NC}"
|
||||
echo " task - 任務管理 (新增)"
|
||||
echo " status - 查看任務狀態"
|
||||
echo " list - 列出所有任務"
|
||||
echo ""
|
||||
echo -e "${PURPLE}📋 問題管理${NC}"
|
||||
echo " issue - 互動式問題管理"
|
||||
echo " check - 檢查問題狀態"
|
||||
|
|
@ -35,6 +30,7 @@ show_menu() {
|
|||
echo -e "${PURPLE}🚀 專案執行管理${NC}"
|
||||
echo " project - 專案管理"
|
||||
echo " phase - 階段管理"
|
||||
echo " status - 查看執行狀態"
|
||||
echo ""
|
||||
echo -e "${PURPLE}🔧 系統檢查${NC}"
|
||||
echo " consistency - 執行一致性檢查"
|
||||
|
|
@ -54,44 +50,6 @@ show_menu() {
|
|||
|
||||
# 主邏輯
|
||||
case "$1" in
|
||||
"task")
|
||||
echo -e "${BLUE}🎯 統一任務管理 - 查看 TASKS.md${NC}"
|
||||
if command -v code > /dev/null 2>&1; then
|
||||
code "$SCRIPT_DIR/TASKS.md"
|
||||
elif command -v open > /dev/null 2>&1; then
|
||||
open "$SCRIPT_DIR/TASKS.md"
|
||||
else
|
||||
echo "請手動打開 TASKS.md 文件"
|
||||
fi
|
||||
;;
|
||||
"status")
|
||||
echo -e "${BLUE}📊 統一任務狀態總覽${NC}"
|
||||
echo "=================================="
|
||||
|
||||
# 統計任務數量
|
||||
if [[ -f "$SCRIPT_DIR/TASKS.md" ]]; then
|
||||
echo -e "${GREEN}📋 任務統計:${NC}"
|
||||
echo "🔥 緊急任務: $(grep -c "### 🔥.*" "$SCRIPT_DIR/TASKS.md" 2>/dev/null || echo "0")"
|
||||
echo "⚠️ 重要任務: $(grep -c "\[ \].*" "$SCRIPT_DIR/TASKS.md" 2>/dev/null | head -1)"
|
||||
echo "📝 一般任務: $(grep -c "### 📝.*" "$SCRIPT_DIR/TASKS.md" 2>/dev/null || echo "0")"
|
||||
echo "✅ 已完成: $(grep -c "\[x\].*✅" "$SCRIPT_DIR/TASKS.md" 2>/dev/null || echo "0")"
|
||||
echo ""
|
||||
echo -e "${YELLOW}💡 執行建議:${NC}"
|
||||
echo " ./dl task - 打開任務管理文件"
|
||||
echo " ./dl list - 快速查看待辦任務"
|
||||
else
|
||||
echo "❌ TASKS.md 文件不存在"
|
||||
fi
|
||||
;;
|
||||
"list")
|
||||
echo -e "${BLUE}📋 當前任務清單${NC}"
|
||||
echo "=================================="
|
||||
if [[ -f "$SCRIPT_DIR/TASKS.md" ]]; then
|
||||
grep -A 3 "^\- \[ \]" "$SCRIPT_DIR/TASKS.md" | head -20
|
||||
else
|
||||
echo "❌ TASKS.md 文件不存在"
|
||||
fi
|
||||
;;
|
||||
"issue")
|
||||
exec "$TOOLS_DIR/issue.sh"
|
||||
;;
|
||||
|
|
@ -117,6 +75,9 @@ case "$1" in
|
|||
shift
|
||||
exec "$TOOLS_DIR/phase.sh" "$@"
|
||||
;;
|
||||
"status")
|
||||
exec "$TOOLS_DIR/project.sh" status
|
||||
;;
|
||||
"consistency")
|
||||
exec "$SCRIPT_DIR/scripts/maintenance_manager.sh" consistency
|
||||
;;
|
||||
|
|
|
|||
|
|
@ -1,195 +1,132 @@
|
|||
# 📚 功能規格文檔總覽 (平台化重組版)
|
||||
# 📚 功能規格文檔總覽
|
||||
|
||||
**建立日期**: 2025-09-09
|
||||
**重組日期**: 2025-09-09
|
||||
**文檔狀態**: ✅ 已完成平台化重組
|
||||
**覆蓋功能**: 5個核心功能模組 × 2個平台
|
||||
**建立日期**: 2025-09-08
|
||||
**文檔狀態**: ✅ 已完成
|
||||
**覆蓋功能**: 5個核心功能模組
|
||||
|
||||
## 🏗️ 新版文檔架構
|
||||
## 📋 文檔目錄
|
||||
|
||||
### 📁 目錄結構
|
||||
```
|
||||
function-specs/
|
||||
├── mobile/ # 移動端專用規格
|
||||
│ ├── 01_情境對話功能規格.md
|
||||
│ ├── 02_詞彙學習功能規格.md
|
||||
│ ├── 03_學習地圖功能規格.md
|
||||
│ ├── 04_道具商店功能規格.md
|
||||
│ ├── 05_用戶認證功能規格.md
|
||||
│ └── README.md
|
||||
├── web/ # Web端專用規格
|
||||
│ └── 詞彙學習功能規格_Web.md # 示例Web端規格
|
||||
├── common/ # 跨平台共同規格
|
||||
│ ├── 業務規則.md # 共同業務邏輯
|
||||
│ ├── 數據模型.md # 數據結構定義
|
||||
│ └── API規格.md # API接口規格
|
||||
└── 平台功能對應表.md # 平台間功能對應關係
|
||||
```
|
||||
### 🎯 已完成的功能規格文檔
|
||||
|
||||
## 📱 移動端規格文檔
|
||||
1. **[01_情境對話功能規格.md](./01_情境對話功能規格.md)**
|
||||
- 📄 **頁數**: 約40頁詳細規格
|
||||
- 🎯 **核心功能**: 沉浸式對話訓練、AI分析回饋、雙重任務系統
|
||||
- 📱 **涉及UI**: 6個主要畫面 + 4個輔助畫面
|
||||
- 💡 **重點特色**: 回覆輔助系統、300秒限時挑戰、三維度評分
|
||||
|
||||
### 🎯 已完成的Mobile端功能規格
|
||||
詳細內容請參考:[mobile/README.md](./mobile/README.md)
|
||||
2. **[02_詞彙學習功能規格.md](./02_詞彙學習功能規格.md)**
|
||||
- 📄 **頁數**: 約35頁詳細規格
|
||||
- 🎯 **核心功能**: 漸進式詞彙學習、多維度練習、流暢度評估
|
||||
- 📱 **涉及UI**: 5個主要畫面 + 3個結果畫面
|
||||
- 💡 **重點特色**: 間隔複習機制、掌握度評估、個人化調整
|
||||
|
||||
**概要統計**:
|
||||
3. **[03_學習地圖功能規格.md](./03_學習地圖功能規格.md)**
|
||||
- 📄 **頁數**: 約30頁詳細規格
|
||||
- 🎯 **核心功能**: 階段化學習路徑、順序解鎖、進度可視化
|
||||
- 📱 **涉及UI**: 5個主要畫面 + 3個輔助畫面
|
||||
- 💡 **重點特色**: 13階段×20劇本架構、星級評價系統
|
||||
|
||||
4. **[04_道具商店功能規格.md](./04_道具商店功能規格.md)**
|
||||
- 📄 **頁數**: 約35頁詳細規格
|
||||
- 🎯 **核心功能**: 鑽石貨幣系統、多層次道具、漸進式付費
|
||||
- 📱 **涉及UI**: 4個主要畫面 + 3個輔助畫面
|
||||
- 💡 **重點特色**: 轉換漏斗設計、組合優惠策略、即時生效
|
||||
|
||||
5. **[05_用戶認證功能規格.md](./05_用戶認證功能規格.md)**
|
||||
- 📄 **頁數**: 約30頁詳細規格
|
||||
- 🎯 **核心功能**: 多元化認證、安全密碼管理、多帳戶支援
|
||||
- 📱 **涉及UI**: 6個主要畫面 + 4個輔助畫面
|
||||
- 💡 **重點特色**: 第三方OAuth、帳戶合併、安全性保護
|
||||
|
||||
## 🎯 規格文檔特點
|
||||
|
||||
### 📊 規格完整性
|
||||
- **功能概述**: 每個功能都有清楚的定位和目標
|
||||
- **畫面細節**: 詳細的欄位規格、驗證規則、顯示條件
|
||||
- **互動設計**: 完整的用戶操作流程和異常處理
|
||||
- **商業邏輯**: 整合營收機制和用戶體驗設計
|
||||
- **技術要求**: 前後端開發注意事項和整合細節
|
||||
|
||||
### 🔗 系統整合性
|
||||
- **跨功能關聯**: 明確說明各功能間的數據和流程整合
|
||||
- **API需求**: 詳細的API呼叫參數和回應格式
|
||||
- **資料結構**: 完整的資料需求和驗證規則
|
||||
- **狀態管理**: 用戶狀態和系統狀態的同步機制
|
||||
|
||||
### 🎨 設計一致性
|
||||
- **視覺規範**: 遵循統一的UI/UX設計指南
|
||||
- **互動模式**: 一致的操作邏輯和回饋機制
|
||||
- **響應式設計**: 多平台和多設備的適配要求
|
||||
- **無障礙支援**: 考量不同使用者需求的設計
|
||||
|
||||
## 📈 解決的問題
|
||||
|
||||
### ✅ 原有問題
|
||||
1. **規格寫法不夠清楚** → 現在有詳細的功能說明、畫面欄位細節、使用者流程
|
||||
2. **缺乏畫面規格** → 每個UI都有完整的欄位規格和互動說明
|
||||
3. **使用者流程不完整** → 提供主流程、分支流程、錯誤流程的完整描述
|
||||
4. **資料說明不足** → 包含API需求、資料結構、驗證規則的詳細說明
|
||||
5. **互動細節缺失** → 詳細的互動元素、狀態變化、動畫效果說明
|
||||
|
||||
### 🎯 新增價值
|
||||
1. **開發效率提升**: 明確的規格減少開發疑問和反覆確認
|
||||
2. **品質保證**: 詳細的測試要點確保功能完整實現
|
||||
3. **團隊協作**: 統一的文檔格式便於跨團隊溝通
|
||||
4. **維護便利**: 完整的版本歷史和參考資源
|
||||
5. **擴展性**: 模板化的結構便於後續功能規格編寫
|
||||
|
||||
## 🛠️ 使用指南
|
||||
|
||||
### 👥 適用角色
|
||||
- **產品經理**: 了解功能完整需求和商業邏輯
|
||||
- **UI/UX設計師**: 參考界面設計和互動規範
|
||||
- **前端開發**: 獲取詳細的界面實現要求
|
||||
- **後端開發**: 了解API需求和資料處理邏輯
|
||||
- **測試工程師**: 參考功能測試和整合測試要點
|
||||
|
||||
### 📋 文檔結構說明
|
||||
每個功能規格文檔都包含以下標準章節:
|
||||
1. **功能概述**: 功能定位、主要功能、適用場景、系統關聯
|
||||
2. **UI畫面**: 主要畫面、輔助畫面清單
|
||||
3. **詳細規格**: 每個畫面的欄位細節、互動元素、操作流程
|
||||
4. **用戶流程**: 主要流程、分支流程、錯誤流程
|
||||
5. **商業邏輯**: 營收機制、遊戲化設計、用戶體驗規則
|
||||
6. **測試要點**: 功能測試、界面測試、整合測試清單
|
||||
7. **開發注意事項**: 前端、後端、整合的技術要求
|
||||
8. **參考資源**: UI截圖、API文檔、設計規範連結
|
||||
|
||||
## 🔄 維護機制
|
||||
|
||||
### 📅 更新週期
|
||||
- **功能變更**: 當功能需求變化時立即更新對應規格
|
||||
- **定期檢查**: 每2週檢視一次規格與實際實現的一致性
|
||||
- **版本管理**: 所有修改都記錄在版本歷史中
|
||||
|
||||
### ✅ 品質保證
|
||||
- **一致性檢查**: 確保各功能規格間的描述一致
|
||||
- **完整性驗證**: 定期檢查是否涵蓋所有必要資訊
|
||||
- **實用性評估**: 根據開發團隊回饋調整規格詳細程度
|
||||
|
||||
## 🎉 成果總結
|
||||
|
||||
### 📊 統計數據
|
||||
- **總頁數**: 約170頁詳細功能規格
|
||||
- **涵蓋UI**: 26個主要畫面 + 17個輔助畫面
|
||||
- **涵蓋UI**: 26個主要畫面 + 17個輔助畫面
|
||||
- **功能模組**: 5個核心功能完整規格
|
||||
- **UI命名**: 統一使用 `UI_*` 格式
|
||||
- **開發指引**: 前後端和整合的完整技術要求
|
||||
|
||||
## 💻 Web端規格文檔
|
||||
|
||||
### 🌐 Web端功能規格 ✅ 已全部完成
|
||||
|
||||
詳細內容請參考:[web/README.md](./web/README.md)
|
||||
|
||||
**概要統計**:
|
||||
- **總頁數**: 約245頁詳細Web端規格
|
||||
- **涉及頁面**: 32個主要頁面 + 14個Web專用頁面
|
||||
- **功能模組**: 5個核心功能完整Web端規格
|
||||
- **UI命名**: 統一使用 `Page_*_W` 格式
|
||||
|
||||
**已完成的Web端規格**:
|
||||
1. **[詞彙學習功能規格_Web.md](./web/詞彙學習功能規格_Web.md)** ✅
|
||||
2. **[情境對話功能規格_Web.md](./web/情境對話功能規格_Web.md)** ✅
|
||||
3. **[學習地圖功能規格_Web.md](./web/學習地圖功能規格_Web.md)** ✅
|
||||
4. **[道具商店功能規格_Web.md](./web/道具商店功能規格_Web.md)** ✅
|
||||
5. **[用戶認證功能規格_Web.md](./web/用戶認證功能規格_Web.md)** ✅
|
||||
|
||||
## 🤝 跨平台共同規格
|
||||
|
||||
### 📋 共同業務邏輯文檔
|
||||
|
||||
1. **[業務規則.md](./common/業務規則.md)** ✅ 已完成
|
||||
- 🎮 **命條系統**: 消耗規則、恢復機制、獲得方式
|
||||
- 💎 **經濟系統**: 鑽石、經驗值、學習幣規則
|
||||
- 📈 **學習進度**: 掌握度分級、難度自適應、間隔複習
|
||||
- 🏆 **成就獎勵**: 成就類型、獎勵機制、權限控制
|
||||
- ⚡ **防作弊**: 時間檢查、操作限制、數據驗證
|
||||
- 🌐 **多語言**: 支援語言、本地化規則
|
||||
|
||||
2. **[數據模型.md](./common/數據模型.md)** ✅ 已完成
|
||||
- 👤 **用戶相關**: User, UserProfile, UserProgress, UserGameStats
|
||||
- 📚 **學習內容**: Vocabulary, Dialogue, StudySession
|
||||
- 🎯 **學習活動**: ActivityResult, UserAnswer
|
||||
- 🏆 **遊戲化**: Achievement, Item, UserInventory
|
||||
- 📊 **分析數據**: LearningAnalytics, SystemMetrics
|
||||
- 🔗 **關係定義**: 實體關係圖、索引策略
|
||||
|
||||
3. **[API規格.md](./common/API規格.md)** ✅ 已完成
|
||||
- 🔐 **認證API**: 註冊、登入、Token刷新、第三方登入
|
||||
- 👤 **用戶API**: 資料管理、進度查詢、遊戲統計
|
||||
- 📚 **內容API**: 詞彙、對話、搜索功能
|
||||
- 🎯 **學習API**: 會話管理、答題、複習系統
|
||||
- 🏆 **遊戲API**: 成就、道具、排行榜
|
||||
- 📊 **分析API**: 學習分析、數據匯出
|
||||
|
||||
## 🔄 平台對應關係
|
||||
|
||||
### 📊 功能對應表
|
||||
詳細內容請參考:[平台功能對應表.md](./平台功能對應表.md)
|
||||
|
||||
**重點摘要**:
|
||||
- **UI命名對應**: Mobile端 `UI_*` ↔ Web端 `Page_*_W`
|
||||
- **功能對應度**: 85%-100% (大部分功能跨平台一致)
|
||||
- **平台專有功能**: Mobile端6項專有、Web端7項專有
|
||||
- **開發優先級**: 核心功能同步開發、重要功能Mobile優先
|
||||
|
||||
## 🎯 重組的好處
|
||||
|
||||
### 🚀 AI協作效率提升
|
||||
- **Token使用優化**: AI只需載入特定平台規格,減少50%以上token消耗
|
||||
- **理解精準度**: 避免混合平台邏輯的混淆,提高AI理解準確性
|
||||
- **開發指引清晰**: 各平台開發團隊獲得專門化的技術指引
|
||||
|
||||
### 📋 維護便利性
|
||||
- **獨立維護**: 各平台規格可獨立更新,不互相影響
|
||||
- **版本控制**: 更清楚的變更追蹤和版本管理
|
||||
- **團隊協作**: 不同平台團隊可專注各自規格
|
||||
|
||||
### 🔄 擴展彈性
|
||||
- **新平台支援**: 未來增加新平台只需新增對應目錄
|
||||
- **功能演化**: 平台特有功能可獨立演進
|
||||
- **技術債務**: 各平台技術債務不會互相拖累
|
||||
|
||||
## 📈 使用指南
|
||||
|
||||
### 👥 不同角色的使用方式
|
||||
|
||||
#### 📱 Mobile開發團隊
|
||||
1. 主要參考 `mobile/` 目錄下的規格文檔
|
||||
2. 共同邏輯參考 `common/` 目錄
|
||||
3. 跨平台對應查看 `平台功能對應表.md`
|
||||
|
||||
#### 💻 Web開發團隊
|
||||
1. 主要參考 `web/` 目錄下的規格文檔
|
||||
2. 共同邏輯參考 `common/` 目錄
|
||||
3. 與Mobile版對比查看對應表
|
||||
|
||||
#### 🔧 後端開發團隊
|
||||
1. 重點參考 `common/API規格.md`
|
||||
2. 數據結構參考 `common/數據模型.md`
|
||||
3. 業務邏輯參考 `common/業務規則.md`
|
||||
|
||||
#### 🎨 產品設計團隊
|
||||
1. 功能定位參考各平台規格的功能概述
|
||||
2. 平台差異參考 `平台功能對應表.md`
|
||||
3. 用戶體驗一致性參考共同業務規則
|
||||
|
||||
### 🤖 AI協作最佳實踐
|
||||
|
||||
#### 指定平台的提示語
|
||||
```
|
||||
"請根據Mobile端規格實作詞彙學習功能"
|
||||
"請參考Web端規格設計頁面布局"
|
||||
"請基於共同API規格設計後端接口"
|
||||
```
|
||||
|
||||
#### 跨平台對比的提示語
|
||||
```
|
||||
"比較Mobile和Web端的詞彙學習功能差異"
|
||||
"分析平台功能對應表中的優先級"
|
||||
"確保共同業務邏輯在兩平台一致實現"
|
||||
```
|
||||
|
||||
## 🔧 開發工作流程
|
||||
|
||||
### 📋 新功能開發流程
|
||||
1. **需求分析**: 確定功能是否需要跨平台實現
|
||||
2. **共同邏輯**: 先設計共同的業務規則和數據模型
|
||||
3. **平台特化**: 分別設計Mobile和Web端的專有規格
|
||||
4. **對應表更新**: 更新平台功能對應表
|
||||
5. **同步開發**: 各平台團隊並行開發
|
||||
|
||||
### 🚀 現有功能改進流程
|
||||
1. **影響評估**: 確定修改是否影響跨平台一致性
|
||||
2. **共同部分**: 優先更新common目錄的共同規格
|
||||
3. **平台專有**: 分別更新各平台的特有規格
|
||||
4. **對應關係**: 必要時更新平台功能對應表
|
||||
5. **測試驗證**: 確保跨平台功能一致性
|
||||
|
||||
## 📊 成果統計
|
||||
|
||||
### 📈 重組完成度
|
||||
- ✅ **目錄結構重組**: 100% 完成
|
||||
- ✅ **Mobile端規格**: 100% 遷移完成 (5個功能規格)
|
||||
- ✅ **共同規格抽取**: 100% 完成 (3個共同文檔)
|
||||
- ✅ **Web端規格**: 100% 完成 (5個完整功能規格)
|
||||
- ✅ **平台對應表**: 100% 完成
|
||||
- ✅ **文檔結構**: 100% 完成
|
||||
|
||||
### 🎯 預期效益
|
||||
- **AI協作效率**: 提升60%以上 (token使用減少、理解準確度提升)
|
||||
- **開發效率**: 各平台開發更專注,預估提升40%
|
||||
- **維護成本**: 獨立維護降低維護複雜度50%
|
||||
- **擴展性**: 為未來新平台支援提供良好架構基礎
|
||||
### 🏆 預期效益
|
||||
- **減少開發疑問**: 預估減少80%的需求澄清時間
|
||||
- **提升開發效率**: 預估提升40%的開發效率
|
||||
- **降低bug發生率**: 預估減少60%的實現偏差問題
|
||||
- **改善程式品質**: 統一標準提升50%的一致性
|
||||
|
||||
---
|
||||
|
||||
**📝 備註**: 本次平台化重組基於AI協作效率優化的需求,確保各平台規格清晰分離,提升團隊協作效率。
|
||||
**📝 備註**: 本文檔總覽基於2025-09-08的分析報告建議執行完成。所有功能規格文檔都遵循統一的模板格式,確保文檔品質和實用性。
|
||||
|
||||
**🔗 相關資源**:
|
||||
- **Git提交**: 已提交Mobile規格和Swagger文檔
|
||||
- **問題記錄**: [ISSUES.md](../../ISSUES.md)
|
||||
- **專案進度**: [PROJECTS.md](../../PROJECTS.md)
|
||||
- **技術文檔**: [../04_technical/](../04_technical/)
|
||||
- **分析報告**: [02_design規格寫法改進需求分析](../../../reports/analysis/2025-09-08_02design規格寫法改進需求分析.md)
|
||||
- **問題記錄**: [ISSUES.md](../../../ISSUES.md) - 02_design規格寫法改進項目
|
||||
- **設計規範**: [ui-ux-guidelines.md](../ui-ux-guidelines.md)
|
||||
- **User Flow**: [user-flow-specification.md](../../04_technical/user-flow-specification.md)
|
||||
|
|
@ -1,568 +0,0 @@
|
|||
# 共同API規格
|
||||
|
||||
## 📋 概述
|
||||
|
||||
**文檔名稱**: 跨平台API規格定義
|
||||
**建立日期**: 2025-09-09
|
||||
**適用平台**: Mobile App / Web App
|
||||
**API版本**: v1
|
||||
**基礎URL**: `https://api.dramaling.com/api/v1`
|
||||
|
||||
本文檔定義了Drama Ling系統中所有平台通用的API接口規格。
|
||||
|
||||
## 🔧 通用設計原則
|
||||
|
||||
### RESTful 設計
|
||||
- 使用標準HTTP方法 (GET, POST, PUT, DELETE)
|
||||
- 資源導向的URL設計
|
||||
- 統一的狀態碼使用
|
||||
- JSON格式的請求和回應
|
||||
|
||||
### 認證機制
|
||||
- JWT Bearer Token認證
|
||||
- 訪問令牌有效期: 1小時
|
||||
- 刷新令牌有效期: 30天
|
||||
- 自動令牌刷新機制
|
||||
|
||||
### 錯誤處理
|
||||
- 統一的錯誤回應格式
|
||||
- 多語言錯誤訊息支援
|
||||
- 詳細的錯誤碼系統
|
||||
|
||||
### 回應格式
|
||||
```typescript
|
||||
interface APIResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: APIError;
|
||||
message: string;
|
||||
meta: {
|
||||
timestamp: string;
|
||||
requestId: string;
|
||||
version: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface APIError {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: any;
|
||||
}
|
||||
```
|
||||
|
||||
## 🔐 認證相關API
|
||||
|
||||
### 用戶註冊
|
||||
```http
|
||||
POST /auth/register
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"password": "securePassword123",
|
||||
"username": "learner123",
|
||||
"nativeLanguage": "zh-TW",
|
||||
"learningLanguages": ["en"]
|
||||
}
|
||||
```
|
||||
|
||||
**回應**:
|
||||
```typescript
|
||||
interface RegisterResponse {
|
||||
userId: string;
|
||||
username: string;
|
||||
email: string;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
userRole: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 用戶登入
|
||||
```http
|
||||
POST /auth/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"password": "securePassword123",
|
||||
"platform": "mobile" | "web",
|
||||
"rememberMe": boolean
|
||||
}
|
||||
```
|
||||
|
||||
### Token刷新
|
||||
```http
|
||||
POST /auth/refresh
|
||||
Authorization: Bearer <refresh_token>
|
||||
```
|
||||
|
||||
### 第三方登入
|
||||
```http
|
||||
POST /auth/social
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"provider": "google" | "apple" | "facebook",
|
||||
"token": "social_provider_token",
|
||||
"platform": "mobile" | "web"
|
||||
}
|
||||
```
|
||||
|
||||
## 👤 用戶資料API
|
||||
|
||||
### 獲取用戶資料
|
||||
```http
|
||||
GET /users/profile
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
### 更新用戶資料
|
||||
```http
|
||||
PUT /users/profile
|
||||
Authorization: Bearer <access_token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"username": "newUsername",
|
||||
"nativeLanguage": "zh-TW",
|
||||
"learningLanguages": ["en", "ja"],
|
||||
"profile": {
|
||||
"firstName": "John",
|
||||
"lastName": "Doe",
|
||||
"bio": "Language learning enthusiast"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 獲取學習進度
|
||||
```http
|
||||
GET /users/progress
|
||||
Authorization: Bearer <access_token>
|
||||
Query Parameters:
|
||||
- skill: string (optional) - vocabulary|dialogue|pronunciation
|
||||
- timeframe: string (optional) - day|week|month|all
|
||||
```
|
||||
|
||||
**回應**:
|
||||
```typescript
|
||||
interface ProgressResponse {
|
||||
overallProgress: number;
|
||||
skillProgress: {
|
||||
vocabulary: SkillProgress;
|
||||
dialogue: SkillProgress;
|
||||
pronunciation: SkillProgress;
|
||||
};
|
||||
recentAchievements: Achievement[];
|
||||
nextGoals: Goal[];
|
||||
}
|
||||
```
|
||||
|
||||
### 獲取遊戲統計
|
||||
```http
|
||||
GET /users/game-stats
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
## 📚 學習內容API
|
||||
|
||||
### 獲取詞彙列表
|
||||
```http
|
||||
GET /vocabulary
|
||||
Authorization: Bearer <access_token>
|
||||
Query Parameters:
|
||||
- language: string (required) - 語言代碼
|
||||
- level: number (optional) - 難度等級 1-5
|
||||
- category: string (optional) - 詞彙分類
|
||||
- limit: number (optional) - 返回數量限制
|
||||
- offset: number (optional) - 偏移量
|
||||
```
|
||||
|
||||
### 獲取詞彙詳情
|
||||
```http
|
||||
GET /vocabulary/{vocabularyId}
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
**回應**:
|
||||
```typescript
|
||||
interface VocabularyDetail {
|
||||
id: string;
|
||||
word: string;
|
||||
pronunciation: string;
|
||||
definitions: Definition[];
|
||||
examples: Example[];
|
||||
audioUrl: string;
|
||||
relatedWords: RelatedWord[];
|
||||
userProgress?: {
|
||||
masteryLevel: number;
|
||||
lastStudied: string;
|
||||
studyCount: number;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 獲取對話列表
|
||||
```http
|
||||
GET /dialogues
|
||||
Authorization: Bearer <access_token>
|
||||
Query Parameters:
|
||||
- difficulty: number (optional)
|
||||
- scenario: string (optional)
|
||||
- completed: boolean (optional)
|
||||
```
|
||||
|
||||
### 獲取對話詳情
|
||||
```http
|
||||
GET /dialogues/{dialogueId}
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
### 搜索學習內容
|
||||
```http
|
||||
GET /content/search
|
||||
Authorization: Bearer <access_token>
|
||||
Query Parameters:
|
||||
- query: string (required) - 搜索關鍵詞
|
||||
- type: string (optional) - vocabulary|dialogue|all
|
||||
- language: string (required)
|
||||
```
|
||||
|
||||
## 🎯 學習活動API
|
||||
|
||||
### 開始學習會話
|
||||
```http
|
||||
POST /learning/sessions
|
||||
Authorization: Bearer <access_token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"type": "vocabulary" | "dialogue" | "review",
|
||||
"contentId": "content_uuid",
|
||||
"settings": {
|
||||
"difficulty": number,
|
||||
"timeLimit": number,
|
||||
"hints": boolean
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**回應**:
|
||||
```typescript
|
||||
interface SessionResponse {
|
||||
sessionId: string;
|
||||
content: LearningContent;
|
||||
questions: Question[];
|
||||
timeLimit: number;
|
||||
lifePointsCost: number;
|
||||
}
|
||||
```
|
||||
|
||||
### 提交答案
|
||||
```http
|
||||
POST /learning/sessions/{sessionId}/answers
|
||||
Authorization: Bearer <access_token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"questionId": "question_uuid",
|
||||
"answer": any,
|
||||
"responseTime": number,
|
||||
"hintsUsed": number
|
||||
}
|
||||
```
|
||||
|
||||
**回應**:
|
||||
```typescript
|
||||
interface AnswerResponse {
|
||||
correct: boolean;
|
||||
correctAnswer?: any;
|
||||
explanation?: string;
|
||||
scoreGained: number;
|
||||
lifePointsLost: number;
|
||||
nextQuestion?: Question;
|
||||
}
|
||||
```
|
||||
|
||||
### 完成學習會話
|
||||
```http
|
||||
POST /learning/sessions/{sessionId}/complete
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
**回應**:
|
||||
```typescript
|
||||
interface SessionCompleteResponse {
|
||||
finalScore: number;
|
||||
accuracy: number;
|
||||
xpGained: number;
|
||||
diamondsGained: number;
|
||||
achievementsUnlocked: Achievement[];
|
||||
nextRecommendations: Recommendation[];
|
||||
}
|
||||
```
|
||||
|
||||
### 獲取複習內容
|
||||
```http
|
||||
GET /learning/review
|
||||
Authorization: Bearer <access_token>
|
||||
Query Parameters:
|
||||
- type: string (optional) - vocabulary|dialogue|all
|
||||
- limit: number (optional)
|
||||
```
|
||||
|
||||
## 🏆 遊戲化系統API
|
||||
|
||||
### 獲取成就列表
|
||||
```http
|
||||
GET /achievements
|
||||
Authorization: Bearer <access_token>
|
||||
Query Parameters:
|
||||
- category: string (optional)
|
||||
- completed: boolean (optional)
|
||||
```
|
||||
|
||||
### 獲取用戶道具
|
||||
```http
|
||||
GET /inventory
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
### 使用道具
|
||||
```http
|
||||
POST /inventory/use
|
||||
Authorization: Bearer <access_token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"itemId": "item_uuid",
|
||||
"quantity": number,
|
||||
"context": {
|
||||
"sessionId": "session_uuid"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 購買道具
|
||||
```http
|
||||
POST /store/purchase
|
||||
Authorization: Bearer <access_token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"itemId": "item_uuid",
|
||||
"quantity": number,
|
||||
"paymentMethod": "diamonds" | "learning_coins" | "real_money"
|
||||
}
|
||||
```
|
||||
|
||||
### 獲取排行榜
|
||||
```http
|
||||
GET /leaderboard
|
||||
Authorization: Bearer <access_token>
|
||||
Query Parameters:
|
||||
- type: string - xp|vocabulary|dialogue|streak
|
||||
- timeframe: string - day|week|month|all
|
||||
- limit: number (optional)
|
||||
```
|
||||
|
||||
## 📊 分析與報告API
|
||||
|
||||
### 獲取學習分析
|
||||
```http
|
||||
GET /analytics/learning
|
||||
Authorization: Bearer <access_token>
|
||||
Query Parameters:
|
||||
- startDate: string (YYYY-MM-DD)
|
||||
- endDate: string (YYYY-MM-DD)
|
||||
- granularity: string - day|week|month
|
||||
```
|
||||
|
||||
**回應**:
|
||||
```typescript
|
||||
interface LearningAnalytics {
|
||||
timeRange: {
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
summary: {
|
||||
totalStudyTime: number;
|
||||
wordsLearned: number;
|
||||
dialoguesCompleted: number;
|
||||
overallAccuracy: number;
|
||||
streakDays: number;
|
||||
};
|
||||
skillBreakdown: SkillAnalytics[];
|
||||
progressTrend: DataPoint[];
|
||||
weakAreas: WeakArea[];
|
||||
recommendations: string[];
|
||||
}
|
||||
```
|
||||
|
||||
### 導出學習數據
|
||||
```http
|
||||
GET /analytics/export
|
||||
Authorization: Bearer <access_token>
|
||||
Query Parameters:
|
||||
- format: string - json|csv|pdf
|
||||
- startDate: string
|
||||
- endDate: string
|
||||
- includePersonal: boolean
|
||||
```
|
||||
|
||||
## 🔄 實時功能API
|
||||
|
||||
### WebSocket連接 (適用於Web端)
|
||||
```
|
||||
WSS /ws/learning
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
**支援事件**:
|
||||
- `session_start` - 學習會話開始
|
||||
- `question_answered` - 回答題目
|
||||
- `achievement_unlocked` - 解鎖成就
|
||||
- `life_point_restored` - 命條恢復
|
||||
- `friend_activity` - 好友活動通知
|
||||
|
||||
### 推送通知API (適用於Mobile端)
|
||||
```http
|
||||
POST /notifications/register
|
||||
Authorization: Bearer <access_token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"deviceToken": "fcm_device_token",
|
||||
"platform": "ios" | "android",
|
||||
"preferences": {
|
||||
"studyReminders": boolean,
|
||||
"achievements": boolean,
|
||||
"friends": boolean
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🌐 多語言支援API
|
||||
|
||||
### 獲取語言包
|
||||
```http
|
||||
GET /localization/{languageCode}
|
||||
Query Parameters:
|
||||
- version: string (optional) - 語言包版本號
|
||||
```
|
||||
|
||||
### 獲取支援語言列表
|
||||
```http
|
||||
GET /localization/languages
|
||||
```
|
||||
|
||||
## 🛡️ 資料保護API
|
||||
|
||||
### 請求數據導出
|
||||
```http
|
||||
POST /privacy/export-request
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
### 請求帳戶刪除
|
||||
```http
|
||||
POST /privacy/delete-request
|
||||
Authorization: Bearer <access_token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"reason": string,
|
||||
"confirmPassword": string
|
||||
}
|
||||
```
|
||||
|
||||
### 更新隱私設定
|
||||
```http
|
||||
PUT /privacy/settings
|
||||
Authorization: Bearer <access_token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"dataCollection": boolean,
|
||||
"analytics": boolean,
|
||||
"marketing": boolean,
|
||||
"thirdPartySharing": boolean
|
||||
}
|
||||
```
|
||||
|
||||
## 📈 API限流規則
|
||||
|
||||
### 頻率限制
|
||||
| 端點類別 | 限制 | 時間窗口 |
|
||||
|---------|------|----------|
|
||||
| 認證相關 | 5次 | 每分鐘 |
|
||||
| 學習內容 | 100次 | 每分鐘 |
|
||||
| 學習活動 | 50次 | 每分鐘 |
|
||||
| 分析報告 | 10次 | 每分鐘 |
|
||||
| 通用查詢 | 200次 | 每分鐘 |
|
||||
|
||||
### 限流回應
|
||||
```http
|
||||
HTTP/1.1 429 Too Many Requests
|
||||
Retry-After: 60
|
||||
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "RATE_LIMIT_EXCEEDED",
|
||||
"message": "Request rate limit exceeded"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🔍 錯誤碼參考
|
||||
|
||||
### 認證錯誤 (4xx)
|
||||
- `INVALID_CREDENTIALS` (401) - 登入憑證錯誤
|
||||
- `TOKEN_EXPIRED` (401) - Token已過期
|
||||
- `TOKEN_INVALID` (401) - Token格式錯誤
|
||||
- `INSUFFICIENT_PERMISSIONS` (403) - 權限不足
|
||||
|
||||
### 業務邏輯錯誤 (4xx)
|
||||
- `INSUFFICIENT_LIFE_POINTS` (402) - 命條不足
|
||||
- `CONTENT_NOT_FOUND` (404) - 學習內容不存在
|
||||
- `SESSION_EXPIRED` (410) - 學習會話已過期
|
||||
- `INVALID_ANSWER_FORMAT` (422) - 答案格式錯誤
|
||||
|
||||
### 系統錯誤 (5xx)
|
||||
- `INTERNAL_SERVER_ERROR` (500) - 內部伺服器錯誤
|
||||
- `DATABASE_ERROR` (503) - 資料庫連接錯誤
|
||||
- `THIRD_PARTY_SERVICE_ERROR` (503) - 第三方服務錯誤
|
||||
|
||||
## 📋 API測試
|
||||
|
||||
### 測試端點
|
||||
```
|
||||
開發環境: https://dev-api.dramaling.com/api/v1
|
||||
測試環境: https://test-api.dramaling.com/api/v1
|
||||
生產環境: https://api.dramaling.com/api/v1
|
||||
```
|
||||
|
||||
### 測試帳號
|
||||
```
|
||||
測試用戶: test@dramaling.com
|
||||
測試密碼: TestUser123456
|
||||
測試Token: 開發環境提供長效測試Token
|
||||
```
|
||||
|
||||
### Postman Collection
|
||||
- 完整API集合下載: `/docs/api/postman-collection.json`
|
||||
- 環境變數設定: `/docs/api/postman-environment.json`
|
||||
|
||||
---
|
||||
|
||||
**文檔狀態**: 🟢 已完成
|
||||
**最後更新**: 2025-09-09
|
||||
**版本**: v1.0
|
||||
**相關文檔**:
|
||||
- `業務規則.md` - 業務邏輯規則
|
||||
- `數據模型.md` - 數據結構定義
|
||||
- `../mobile/` - 移動端功能規格
|
||||
- `../web/` - Web端功能規格
|
||||
- `/swagger-ui.html` - 互動式API文檔
|
||||
|
|
@ -1,522 +0,0 @@
|
|||
# 共同數據模型
|
||||
|
||||
## 📋 概述
|
||||
|
||||
**文檔名稱**: 跨平台數據模型定義
|
||||
**建立日期**: 2025-09-09
|
||||
**適用平台**: Mobile App / Web App
|
||||
**負責團隊**: 後端/數據庫設計
|
||||
|
||||
本文檔定義了Drama Ling系統中所有核心數據實體的結構和關係。
|
||||
|
||||
## 👤 用戶相關數據模型
|
||||
|
||||
### User - 用戶基本資訊
|
||||
```typescript
|
||||
interface User {
|
||||
id: string; // UUID用戶唯一標識符
|
||||
email: string; // 登入用電子郵件
|
||||
username: string; // 用戶顯示名稱
|
||||
passwordHash: string; // 密碼雜湊 (bcrypt)
|
||||
|
||||
// 個人資料
|
||||
profile: UserProfile;
|
||||
|
||||
// 學習相關
|
||||
nativeLanguage: string; // 母語 (ISO 639-1)
|
||||
learningLanguages: string[]; // 學習語言陣列
|
||||
learningLevel: string; // 整體學習程度
|
||||
|
||||
// 系統相關
|
||||
role: UserRole; // 用戶角色
|
||||
subscriptionStatus: SubscriptionStatus;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
lastLoginAt: Date;
|
||||
|
||||
// 遊戲化數據
|
||||
gameStats: UserGameStats;
|
||||
}
|
||||
|
||||
interface UserProfile {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
avatar?: string; // 頭像URL
|
||||
bio?: string; // 個人簡介
|
||||
timezone: string; // 時區
|
||||
preferredStudyTime?: string; // 偏好學習時間
|
||||
}
|
||||
|
||||
interface UserGameStats {
|
||||
totalXP: number; // 總經驗值
|
||||
currentLevel: number; // 當前等級
|
||||
diamonds: number; // 鑽石數量
|
||||
learningCoins: number; // 學習幣數量
|
||||
lifePoints: number; // 當前命條數
|
||||
maxLifePoints: number; // 命條上限
|
||||
lastLifePointRestore: Date; // 上次命條恢復時間
|
||||
|
||||
// 統計數據
|
||||
totalStudyDays: number; // 總學習天數
|
||||
consecutiveStudyDays: number; // 連續學習天數
|
||||
totalWordsLearned: number; // 總學習詞彙數
|
||||
totalDialoguesCompleted: number; // 總完成對話數
|
||||
|
||||
// 成就數據
|
||||
achievements: Achievement[];
|
||||
}
|
||||
```
|
||||
|
||||
### UserProgress - 用戶學習進度
|
||||
```typescript
|
||||
interface UserProgress {
|
||||
id: string;
|
||||
userId: string;
|
||||
|
||||
// 整體進度
|
||||
overallProgress: number; // 0-100 整體學習進度
|
||||
currentPhase: string; // 當前學習階段
|
||||
|
||||
// 各技能進度
|
||||
vocabularyProgress: SkillProgress;
|
||||
dialogueProgress: SkillProgress;
|
||||
pronunciationProgress: SkillProgress;
|
||||
grammarProgress: SkillProgress;
|
||||
|
||||
// 學習路徑
|
||||
completedLevels: string[]; // 已完成關卡ID陣列
|
||||
unlockedLevels: string[]; // 已解鎖關卡ID陣列
|
||||
currentLevel: string; // 當前學習關卡ID
|
||||
|
||||
// 複習數據
|
||||
reviewQueue: ReviewItem[]; // 複習佇列
|
||||
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface SkillProgress {
|
||||
level: number; // 技能等級 1-10
|
||||
xp: number; // 該技能經驗值
|
||||
accuracy: number; // 準確率 0-100
|
||||
fluency: number; // 流暢度 0-100
|
||||
lastPracticed: Date; // 上次練習時間
|
||||
}
|
||||
|
||||
interface ReviewItem {
|
||||
contentId: string; // 內容ID (詞彙/對話等)
|
||||
contentType: 'vocabulary' | 'dialogue' | 'grammar';
|
||||
nextReviewAt: Date; // 下次複習時間
|
||||
reviewCount: number; // 已複習次數
|
||||
difficulty: number; // 當前難度 1-5
|
||||
masteryLevel: number; // 掌握程度 0-100
|
||||
}
|
||||
```
|
||||
|
||||
## 📚 學習內容數據模型
|
||||
|
||||
### Vocabulary - 詞彙數據
|
||||
```typescript
|
||||
interface Vocabulary {
|
||||
id: string;
|
||||
word: string; // 詞彙本體
|
||||
language: string; // 語言代碼
|
||||
|
||||
// 詞彙資訊
|
||||
pronunciation: string; // IPA音標
|
||||
partOfSpeech: string; // 詞性
|
||||
difficulty: number; // 難度等級 1-5
|
||||
frequency: number; // 使用頻率評分
|
||||
|
||||
// 釋義
|
||||
definitions: Definition[];
|
||||
examples: Example[];
|
||||
|
||||
// 音頻
|
||||
audioUrl: string; // 標準發音音頻URL
|
||||
slowAudioUrl?: string; // 慢速發音音頻URL
|
||||
|
||||
// 分類
|
||||
categories: string[]; // 詞彙分類標籤
|
||||
topics: string[]; // 相關主題
|
||||
|
||||
// 關聯
|
||||
synonyms: string[]; // 同義詞ID陣列
|
||||
antonyms: string[]; // 反義詞ID陣列
|
||||
relatedWords: string[]; // 相關詞彙ID陣列
|
||||
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface Definition {
|
||||
id: string;
|
||||
definition: string; // 定義文字
|
||||
language: string; // 定義語言
|
||||
context?: string; // 使用情境
|
||||
formality?: 'formal' | 'informal' | 'neutral';
|
||||
}
|
||||
|
||||
interface Example {
|
||||
id: string;
|
||||
sentence: string; // 例句
|
||||
translation?: string; // 翻譯
|
||||
audioUrl?: string; // 例句音頻
|
||||
context?: string; // 使用情境
|
||||
}
|
||||
```
|
||||
|
||||
### Dialogue - 對話內容
|
||||
```typescript
|
||||
interface Dialogue {
|
||||
id: string;
|
||||
title: string; // 對話標題
|
||||
description: string; // 對話描述
|
||||
|
||||
// 情境設定
|
||||
scenario: DialogueScenario;
|
||||
|
||||
// 對話內容
|
||||
messages: DialogueMessage[];
|
||||
|
||||
// 學習目標
|
||||
learningObjectives: string[]; // 學習目標陣列
|
||||
targetVocabulary: string[]; // 目標詞彙ID陣列
|
||||
grammarPoints: string[]; // 語法重點
|
||||
|
||||
// 元數據
|
||||
difficulty: number; // 難度等級
|
||||
estimatedDuration: number; // 預估完成時間(分鐘)
|
||||
tags: string[]; // 標籤
|
||||
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface DialogueScenario {
|
||||
setting: string; // 場景設定
|
||||
characters: Character[]; // 角色資訊
|
||||
culturalContext?: string; // 文化背景
|
||||
situation: string; // 具體情況
|
||||
}
|
||||
|
||||
interface Character {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string; // 角色定位
|
||||
personality: string; // 性格特點
|
||||
background: string; // 背景設定
|
||||
avatarUrl?: string; // 角色頭像
|
||||
}
|
||||
|
||||
interface DialogueMessage {
|
||||
id: string;
|
||||
characterId: string; // 說話角色ID
|
||||
content: string; // 對話內容
|
||||
translation?: string; // 翻譯
|
||||
audioUrl?: string; // 語音檔URL
|
||||
|
||||
// AI分析數據
|
||||
intent?: string; // 對話意圖
|
||||
emotion?: string; // 情感色彩
|
||||
formalityLevel?: string; // 正式程度
|
||||
|
||||
// 學習提示
|
||||
hints?: string[]; // 提示信息
|
||||
alternatives?: string[]; // 替代回答
|
||||
}
|
||||
```
|
||||
|
||||
## 🎯 學習活動數據模型
|
||||
|
||||
### StudySession - 學習會話
|
||||
```typescript
|
||||
interface StudySession {
|
||||
id: string;
|
||||
userId: string;
|
||||
|
||||
// 會話資訊
|
||||
type: 'vocabulary' | 'dialogue' | 'review' | 'challenge';
|
||||
contentId: string; // 學習內容ID
|
||||
startTime: Date;
|
||||
endTime?: Date;
|
||||
duration?: number; // 實際學習時長(秒)
|
||||
|
||||
// 學習結果
|
||||
completed: boolean;
|
||||
score: number; // 得分 0-100
|
||||
accuracy: number; // 準確率 0-100
|
||||
|
||||
// 詳細數據
|
||||
activities: ActivityResult[];
|
||||
|
||||
// 獎勵
|
||||
xpGained: number;
|
||||
diamondsGained: number;
|
||||
achievementsUnlocked: string[];
|
||||
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface ActivityResult {
|
||||
id: string;
|
||||
type: 'choice_question' | 'matching' | 'dialogue_turn' | 'pronunciation';
|
||||
contentId: string;
|
||||
|
||||
// 回答數據
|
||||
userAnswer: any; // 用戶回答
|
||||
correctAnswer: any; // 正確答案
|
||||
isCorrect: boolean;
|
||||
responseTime: number; // 回答時間(秒)
|
||||
|
||||
// 分析數據
|
||||
difficulty: number; // 題目難度
|
||||
hintUsed: boolean; // 是否使用提示
|
||||
skipCount: number; // 跳過次數
|
||||
|
||||
timestamp: Date;
|
||||
}
|
||||
```
|
||||
|
||||
### UserAnswer - 用戶回答記錄
|
||||
```typescript
|
||||
interface UserAnswer {
|
||||
id: string;
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
|
||||
// 問題資訊
|
||||
questionId: string;
|
||||
questionType: string;
|
||||
content: any; // 問題內容
|
||||
|
||||
// 回答資訊
|
||||
answer: any; // 用戶回答
|
||||
isCorrect: boolean;
|
||||
responseTime: number; // 回答時間(毫秒)
|
||||
attempts: number; // 嘗試次數
|
||||
|
||||
// 輔助使用
|
||||
hintsUsed: number; // 使用提示次數
|
||||
timeExtensions: number; // 延時次數
|
||||
|
||||
// AI評估 (針對開放性回答)
|
||||
aiScore?: number; // AI評分 0-100
|
||||
feedback?: string; // AI反饋
|
||||
|
||||
createdAt: Date;
|
||||
}
|
||||
```
|
||||
|
||||
## 🏆 遊戲化數據模型
|
||||
|
||||
### Achievement - 成就系統
|
||||
```typescript
|
||||
interface Achievement {
|
||||
id: string;
|
||||
name: string; // 成就名稱
|
||||
description: string; // 成就描述
|
||||
category: AchievementCategory;
|
||||
|
||||
// 達成條件
|
||||
requirements: AchievementRequirement[];
|
||||
|
||||
// 獎勵
|
||||
rewards: Reward[];
|
||||
|
||||
// 元數據
|
||||
iconUrl: string; // 成就圖標
|
||||
rarity: 'common' | 'rare' | 'epic' | 'legendary';
|
||||
isHidden: boolean; // 是否為隱藏成就
|
||||
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface AchievementRequirement {
|
||||
type: string; // 要求類型
|
||||
target: number; // 目標數值
|
||||
description: string; // 要求描述
|
||||
}
|
||||
|
||||
interface Reward {
|
||||
type: 'xp' | 'diamonds' | 'title' | 'avatar' | 'theme';
|
||||
amount?: number; // 數量 (針對XP/鑽石)
|
||||
itemId?: string; // 物品ID (針對稱號/頭像/主題)
|
||||
}
|
||||
|
||||
interface UserAchievement {
|
||||
id: string;
|
||||
userId: string;
|
||||
achievementId: string;
|
||||
|
||||
progress: number; // 進度 0-100
|
||||
completed: boolean;
|
||||
completedAt?: Date;
|
||||
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
```
|
||||
|
||||
### Item - 道具/物品系統
|
||||
```typescript
|
||||
interface Item {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: ItemCategory;
|
||||
|
||||
// 效果
|
||||
effects: ItemEffect[];
|
||||
|
||||
// 購買/使用
|
||||
price: Price[]; // 多種貨幣價格
|
||||
consumable: boolean; // 是否為消耗品
|
||||
stackable: boolean; // 是否可堆疊
|
||||
maxStack?: number; // 最大堆疊數量
|
||||
|
||||
// 元數據
|
||||
iconUrl: string;
|
||||
rarity: ItemRarity;
|
||||
|
||||
// 購買限制
|
||||
dailyLimit?: number; // 每日購買限制
|
||||
requiresSubscription: boolean;
|
||||
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface ItemEffect {
|
||||
type: 'restore_life' | 'double_xp' | 'skip_question' | 'extra_hint';
|
||||
value: number; // 效果數值
|
||||
duration?: number; // 持續時間(秒)
|
||||
}
|
||||
|
||||
interface Price {
|
||||
currency: 'diamonds' | 'learning_coins' | 'real_money';
|
||||
amount: number;
|
||||
}
|
||||
|
||||
interface UserInventory {
|
||||
id: string;
|
||||
userId: string;
|
||||
itemId: string;
|
||||
quantity: number;
|
||||
|
||||
// 使用記錄
|
||||
totalUsed: number;
|
||||
lastUsed?: Date;
|
||||
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
```
|
||||
|
||||
## 📊 分析數據模型
|
||||
|
||||
### LearningAnalytics - 學習分析
|
||||
```typescript
|
||||
interface LearningAnalytics {
|
||||
id: string;
|
||||
userId: string;
|
||||
date: Date; // 分析日期
|
||||
|
||||
// 學習時間分析
|
||||
totalStudyTime: number; // 總學習時間(分鐘)
|
||||
sessionCount: number; // 學習會話數
|
||||
averageSessionLength: number; // 平均會話時長
|
||||
|
||||
// 學習效果分析
|
||||
wordsLearned: number; // 當日學習詞彙數
|
||||
dialoguesCompleted: number; // 完成對話數
|
||||
overallAccuracy: number; // 整體準確率
|
||||
|
||||
// 技能分析
|
||||
vocabularyAccuracy: number;
|
||||
dialogueAccuracy: number;
|
||||
pronunciationScore: number;
|
||||
|
||||
// 學習模式分析
|
||||
preferredStudyTime: string; // 偏好學習時段
|
||||
mostActiveHour: number; // 最活躍小時
|
||||
learningStreak: number; // 連續學習天數
|
||||
|
||||
// 困難分析
|
||||
difficultWords: string[]; // 困難詞彙ID陣列
|
||||
weakAreas: string[]; // 薄弱領域
|
||||
improvementSuggestions: string[]; // 改進建議
|
||||
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface SystemMetrics {
|
||||
id: string;
|
||||
date: Date;
|
||||
|
||||
// 用戶活躍度
|
||||
activeUsers: number;
|
||||
newUsers: number;
|
||||
returningUsers: number;
|
||||
|
||||
// 學習數據
|
||||
totalSessions: number;
|
||||
averageSessionLength: number;
|
||||
completionRate: number;
|
||||
|
||||
// 內容熱門度
|
||||
popularDialogues: string[];
|
||||
popularVocabulary: string[];
|
||||
|
||||
// 系統效能
|
||||
averageResponseTime: number;
|
||||
errorRate: number;
|
||||
|
||||
createdAt: Date;
|
||||
}
|
||||
```
|
||||
|
||||
## 🔗 數據關係定義
|
||||
|
||||
### 主要實體關係
|
||||
```
|
||||
User (1) ←→ (1) UserProgress
|
||||
User (1) ←→ (*) StudySession
|
||||
User (1) ←→ (*) UserAnswer
|
||||
User (1) ←→ (*) UserAchievement
|
||||
User (1) ←→ (1) UserInventory
|
||||
|
||||
Vocabulary (1) ←→ (*) UserAnswer
|
||||
Dialogue (1) ←→ (*) StudySession
|
||||
Achievement (1) ←→ (*) UserAchievement
|
||||
|
||||
StudySession (1) ←→ (*) ActivityResult
|
||||
StudySession (1) ←→ (*) UserAnswer
|
||||
```
|
||||
|
||||
### 索引策略
|
||||
```sql
|
||||
-- 用戶相關索引
|
||||
CREATE INDEX idx_user_email ON users(email);
|
||||
CREATE INDEX idx_user_role ON users(role);
|
||||
CREATE INDEX idx_user_subscription ON users(subscription_status);
|
||||
|
||||
-- 學習數據索引
|
||||
CREATE INDEX idx_study_session_user_date ON study_sessions(user_id, start_time);
|
||||
CREATE INDEX idx_user_answer_session ON user_answers(session_id);
|
||||
CREATE INDEX idx_user_progress_user ON user_progress(user_id);
|
||||
|
||||
-- 內容相關索引
|
||||
CREATE INDEX idx_vocabulary_language_difficulty ON vocabulary(language, difficulty);
|
||||
CREATE INDEX idx_dialogue_difficulty_tags ON dialogues(difficulty, tags);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**文檔狀態**: 🟢 已完成
|
||||
**最後更新**: 2025-09-09
|
||||
**版本**: v1.0
|
||||
**相關文檔**:
|
||||
- `業務規則.md` - 業務邏輯規則
|
||||
- `API規格.md` - API接口定義
|
||||
- `../mobile/` - 移動端功能規格
|
||||
- `../web/` - Web端功能規格
|
||||
|
|
@ -1,181 +0,0 @@
|
|||
# 共同業務規則
|
||||
|
||||
## 📋 概述
|
||||
|
||||
**文檔名稱**: 跨平台共同業務規則
|
||||
**建立日期**: 2025-09-09
|
||||
**適用平台**: Mobile App / Web App
|
||||
**負責團隊**: 產品/設計/開發
|
||||
|
||||
本文檔定義了Drama Ling語言學習系統中跨平台通用的業務規則和邏輯。
|
||||
|
||||
## 🎮 命條系統 (Life Points System)
|
||||
|
||||
### 基本規則
|
||||
- **初始命條**: 新用戶獲得5個命條
|
||||
- **最大命條**: 普通用戶5個,訂閱用戶10個
|
||||
- **恢復機制**: 每30分鐘自動恢復1個命條
|
||||
- **命條消耗**: 答錯題目扣除1個命條
|
||||
|
||||
### 消耗場景
|
||||
| 場景 | 命條消耗 | 說明 |
|
||||
|------|----------|------|
|
||||
| 答錯選擇題 | 1個 | 詞彙學習、對話練習 |
|
||||
| 跳過題目 | 1個 | 視為答錯處理 |
|
||||
| 對話失敗 | 2個 | 情境對話完全失敗 |
|
||||
| 挑戰失敗 | 3個 | 特殊挑戰任務失敗 |
|
||||
|
||||
### 獲得命條方式
|
||||
- **自動恢復**: 每30分鐘恢復1個
|
||||
- **廣告觀看**: 觀看廣告恢復1個命條 (每日3次)
|
||||
- **道具購買**: 使用鑽石購買命條補充包
|
||||
- **訂閱獎勵**: 訂閱用戶命條上限提升至10個
|
||||
|
||||
## 💎 經濟系統 (Economy System)
|
||||
|
||||
### 貨幣類型
|
||||
1. **鑽石 (Diamonds)**: 高級貨幣,可購買道具和服務
|
||||
2. **經驗值 (XP)**: 學習進度貨幣,用於解鎖內容
|
||||
3. **學習幣 (Learning Coins)**: 日常活動貨幣,購買基礎道具
|
||||
|
||||
### 經驗值獲得規則
|
||||
| 活動類型 | 經驗值獲得 | 條件 |
|
||||
|----------|------------|------|
|
||||
| 完成詞彙學習 | 50-100 XP | 根據準確率調整 |
|
||||
| 完成對話練習 | 100-200 XP | 根據對話質量調整 |
|
||||
| 連續學習 | 額外20% XP | 連續學習天數獎勵 |
|
||||
| 完美通關 | 雙倍 XP | 全部答對且用時短 |
|
||||
| 每日任務 | 50 XP | 完成每日學習目標 |
|
||||
|
||||
### 鑽石獲得與消費
|
||||
**獲得方式**:
|
||||
- 每日登入獎勵: 2鑽石
|
||||
- 完成成就: 10-50鑽石
|
||||
- 觀看廣告: 1鑽石 (每日5次)
|
||||
- 內購: 實際貨幣購買
|
||||
|
||||
**消費項目**:
|
||||
- 命條補充包 (5個): 20鑽石
|
||||
- 時光卷道具: 10鑽石
|
||||
- 提示道具: 5鑽石
|
||||
- 解鎖高級內容: 100-500鑽石
|
||||
|
||||
## 📈 學習進度系統
|
||||
|
||||
### 掌握度分級
|
||||
- **初識 (Beginner)**: 0-25% 掌握度
|
||||
- **熟悉 (Familiar)**: 26-60% 掌握度
|
||||
- **應用 (Applied)**: 61-85% 掌握度
|
||||
- **掌握 (Mastered)**: 86-100% 掌握度
|
||||
|
||||
### 難度自適應算法
|
||||
```
|
||||
新難度 = 基礎難度 + 表現調整係數
|
||||
表現調整係數 = (正確率 - 0.7) × 0.5 + (平均反應時間調整)
|
||||
|
||||
若正確率 > 85%: 提升一個難度級別
|
||||
若正確率 < 40%: 降低一個難度級別
|
||||
若連續3次滿分: 跳過下一個同類題目
|
||||
```
|
||||
|
||||
### 間隔複習機制
|
||||
基於艾賓浩斯遺忘曲線:
|
||||
- **第1次複習**: 學習後1小時
|
||||
- **第2次複習**: 學習後1天
|
||||
- **第3次複習**: 學習後3天
|
||||
- **第4次複習**: 學習後7天
|
||||
- **第5次複習**: 學習後15天
|
||||
- **後續複習**: 每30天一次
|
||||
|
||||
## 🏆 成就與獎勵系統
|
||||
|
||||
### 成就類型
|
||||
1. **學習里程碑**: 累計學習天數、掌握詞彙數量
|
||||
2. **技能成就**: 對話流暢度、發音準確度
|
||||
3. **挑戰成就**: 連續答對、完美通關次數
|
||||
4. **社交成就**: 分享學習成果、邀請好友
|
||||
|
||||
### 獎勵機制
|
||||
| 成就等級 | 鑽石獎勵 | 經驗值獎勵 | 特殊獎勵 |
|
||||
|----------|----------|------------|----------|
|
||||
| 青銅 | 10鑽石 | 100 XP | 稱號 |
|
||||
| 白銀 | 25鑽石 | 250 XP | 頭像框 |
|
||||
| 黃金 | 50鑽石 | 500 XP | 特殊主題 |
|
||||
| 鉑金 | 100鑽石 | 1000 XP | 高級功能 |
|
||||
|
||||
## 🔐 權限控制系統
|
||||
|
||||
### 用戶角色
|
||||
```typescript
|
||||
enum UserRole {
|
||||
FREE_USER = "free_user", // 免費用戶
|
||||
SUBSCRIBER = "subscriber", // 訂閱用戶
|
||||
ADMIN = "admin" // 管理員
|
||||
}
|
||||
```
|
||||
|
||||
### 功能權限矩陣
|
||||
| 功能 | 免費用戶 | 訂閱用戶 | 管理員 |
|
||||
|------|----------|----------|---------|
|
||||
| 基礎對話練習 | 3次/日 | 無限制 | 無限制 |
|
||||
| 高級對話功能 | ❌ | ✅ | ✅ |
|
||||
| 詞彙學習 | 基礎詞庫 | 完整詞庫 | 完整詞庫 |
|
||||
| AI分析報告 | 簡化版 | 詳細版 | 完整版 |
|
||||
| 離線模式 | ❌ | ✅ | ✅ |
|
||||
| 數據匯出 | ❌ | ✅ | ✅ |
|
||||
| 管理功能 | ❌ | ❌ | ✅ |
|
||||
|
||||
## ⚡ 防作弊機制
|
||||
|
||||
### 答題時間檢查
|
||||
- **最短答題時間**: 1秒 (防止機器人)
|
||||
- **合理答題時間**: 3-60秒 (根據題目類型)
|
||||
- **超時處理**: 超過60秒視為跳過
|
||||
|
||||
### 連續操作限制
|
||||
- **連續答對上限**: 同一題目類型連續答對50次觸發人機驗證
|
||||
- **學習頻率限制**: 每小時最多完成20個學習單元
|
||||
- **異常行為偵測**: IP異常、設備異常自動標記
|
||||
|
||||
### 學習數據驗證
|
||||
- **學習時間合理性**: 每日學習時間不可超過12小時
|
||||
- **進度跳躍檢查**: 難度提升過快觸發審核
|
||||
- **成績異常檢測**: 突然大幅提升觸發人工檢查
|
||||
|
||||
## 🌐 多語言支援
|
||||
|
||||
### 支援語言
|
||||
- **界面語言**: 中文(繁體/簡體)、英文、日文、韓文
|
||||
- **學習語言**: 英文、日文、韓文、西班牙文、法文
|
||||
- **音頻語言**: 支援所有學習語言的標準發音
|
||||
|
||||
### 本地化規則
|
||||
- **日期格式**: 根據用戶地區自動調整
|
||||
- **數字格式**: 支援不同地區的數字分隔符
|
||||
- **貨幣顯示**: 根據用戶所在地區顯示本地貨幣
|
||||
- **時區處理**: 自動根據用戶時區調整時間顯示
|
||||
|
||||
## 📊 數據分析規則
|
||||
|
||||
### 學習分析維度
|
||||
1. **學習效率**: 單位時間掌握詞彙數/對話完成數
|
||||
2. **知識保持率**: 間隔複習中的正確率變化
|
||||
3. **學習偏好**: 用戶偏愛的學習模式和時間
|
||||
4. **難點識別**: 用戶容易犯錯的知識點
|
||||
|
||||
### 隱私保護
|
||||
- **數據匿名化**: 個人識別信息在分析前移除
|
||||
- **本地計算**: 敏感數據優先在本地處理
|
||||
- **用戶同意**: 數據使用需要用戶明確同意
|
||||
- **數據保留**: 學習數據保留期限不超過2年
|
||||
|
||||
---
|
||||
|
||||
**文檔狀態**: 🟢 已完成
|
||||
**最後更新**: 2025-09-09
|
||||
**版本**: v1.0
|
||||
**相關文檔**:
|
||||
- `數據模型.md` - 數據結構定義
|
||||
- `API規格.md` - API接口設計
|
||||
- `mobile/` - 移動端功能規格
|
||||
- `web/` - Web端功能規格
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
# 📚 功能規格文檔總覽
|
||||
|
||||
**建立日期**: 2025-09-08
|
||||
**文檔狀態**: ✅ 已完成
|
||||
**覆蓋功能**: 5個核心功能模組
|
||||
|
||||
## 📋 文檔目錄
|
||||
|
||||
### 🎯 已完成的功能規格文檔
|
||||
|
||||
1. **[01_情境對話功能規格.md](./01_情境對話功能規格.md)**
|
||||
- 📄 **頁數**: 約40頁詳細規格
|
||||
- 🎯 **核心功能**: 沉浸式對話訓練、AI分析回饋、雙重任務系統
|
||||
- 📱 **涉及UI**: 6個主要畫面 + 4個輔助畫面
|
||||
- 💡 **重點特色**: 回覆輔助系統、300秒限時挑戰、三維度評分
|
||||
|
||||
2. **[02_詞彙學習功能規格.md](./02_詞彙學習功能規格.md)**
|
||||
- 📄 **頁數**: 約35頁詳細規格
|
||||
- 🎯 **核心功能**: 漸進式詞彙學習、多維度練習、流暢度評估
|
||||
- 📱 **涉及UI**: 5個主要畫面 + 3個結果畫面
|
||||
- 💡 **重點特色**: 間隔複習機制、掌握度評估、個人化調整
|
||||
|
||||
3. **[03_學習地圖功能規格.md](./03_學習地圖功能規格.md)**
|
||||
- 📄 **頁數**: 約30頁詳細規格
|
||||
- 🎯 **核心功能**: 階段化學習路徑、順序解鎖、進度可視化
|
||||
- 📱 **涉及UI**: 5個主要畫面 + 3個輔助畫面
|
||||
- 💡 **重點特色**: 13階段×20劇本架構、星級評價系統
|
||||
|
||||
4. **[04_道具商店功能規格.md](./04_道具商店功能規格.md)**
|
||||
- 📄 **頁數**: 約35頁詳細規格
|
||||
- 🎯 **核心功能**: 鑽石貨幣系統、多層次道具、漸進式付費
|
||||
- 📱 **涉及UI**: 4個主要畫面 + 3個輔助畫面
|
||||
- 💡 **重點特色**: 轉換漏斗設計、組合優惠策略、即時生效
|
||||
|
||||
5. **[05_用戶認證功能規格.md](./05_用戶認證功能規格.md)**
|
||||
- 📄 **頁數**: 約30頁詳細規格
|
||||
- 🎯 **核心功能**: 多元化認證、安全密碼管理、多帳戶支援
|
||||
- 📱 **涉及UI**: 6個主要畫面 + 4個輔助畫面
|
||||
- 💡 **重點特色**: 第三方OAuth、帳戶合併、安全性保護
|
||||
|
||||
## 🎯 規格文檔特點
|
||||
|
||||
### 📊 規格完整性
|
||||
- **功能概述**: 每個功能都有清楚的定位和目標
|
||||
- **畫面細節**: 詳細的欄位規格、驗證規則、顯示條件
|
||||
- **互動設計**: 完整的用戶操作流程和異常處理
|
||||
- **商業邏輯**: 整合營收機制和用戶體驗設計
|
||||
- **技術要求**: 前後端開發注意事項和整合細節
|
||||
|
||||
### 🔗 系統整合性
|
||||
- **跨功能關聯**: 明確說明各功能間的數據和流程整合
|
||||
- **API需求**: 詳細的API呼叫參數和回應格式
|
||||
- **資料結構**: 完整的資料需求和驗證規則
|
||||
- **狀態管理**: 用戶狀態和系統狀態的同步機制
|
||||
|
||||
### 🎨 設計一致性
|
||||
- **視覺規範**: 遵循統一的UI/UX設計指南
|
||||
- **互動模式**: 一致的操作邏輯和回饋機制
|
||||
- **響應式設計**: 多平台和多設備的適配要求
|
||||
- **無障礙支援**: 考量不同使用者需求的設計
|
||||
|
||||
## 📈 解決的問題
|
||||
|
||||
### ✅ 原有問題
|
||||
1. **規格寫法不夠清楚** → 現在有詳細的功能說明、畫面欄位細節、使用者流程
|
||||
2. **缺乏畫面規格** → 每個UI都有完整的欄位規格和互動說明
|
||||
3. **使用者流程不完整** → 提供主流程、分支流程、錯誤流程的完整描述
|
||||
4. **資料說明不足** → 包含API需求、資料結構、驗證規則的詳細說明
|
||||
5. **互動細節缺失** → 詳細的互動元素、狀態變化、動畫效果說明
|
||||
|
||||
### 🎯 新增價值
|
||||
1. **開發效率提升**: 明確的規格減少開發疑問和反覆確認
|
||||
2. **品質保證**: 詳細的測試要點確保功能完整實現
|
||||
3. **團隊協作**: 統一的文檔格式便於跨團隊溝通
|
||||
4. **維護便利**: 完整的版本歷史和參考資源
|
||||
5. **擴展性**: 模板化的結構便於後續功能規格編寫
|
||||
|
||||
## 🛠️ 使用指南
|
||||
|
||||
### 👥 適用角色
|
||||
- **產品經理**: 了解功能完整需求和商業邏輯
|
||||
- **UI/UX設計師**: 參考界面設計和互動規範
|
||||
- **前端開發**: 獲取詳細的界面實現要求
|
||||
- **後端開發**: 了解API需求和資料處理邏輯
|
||||
- **測試工程師**: 參考功能測試和整合測試要點
|
||||
|
||||
### 📋 文檔結構說明
|
||||
每個功能規格文檔都包含以下標準章節:
|
||||
1. **功能概述**: 功能定位、主要功能、適用場景、系統關聯
|
||||
2. **UI畫面**: 主要畫面、輔助畫面清單
|
||||
3. **詳細規格**: 每個畫面的欄位細節、互動元素、操作流程
|
||||
4. **用戶流程**: 主要流程、分支流程、錯誤流程
|
||||
5. **商業邏輯**: 營收機制、遊戲化設計、用戶體驗規則
|
||||
6. **測試要點**: 功能測試、界面測試、整合測試清單
|
||||
7. **開發注意事項**: 前端、後端、整合的技術要求
|
||||
8. **參考資源**: UI截圖、API文檔、設計規範連結
|
||||
|
||||
## 🔄 維護機制
|
||||
|
||||
### 📅 更新週期
|
||||
- **功能變更**: 當功能需求變化時立即更新對應規格
|
||||
- **定期檢查**: 每2週檢視一次規格與實際實現的一致性
|
||||
- **版本管理**: 所有修改都記錄在版本歷史中
|
||||
|
||||
### ✅ 品質保證
|
||||
- **一致性檢查**: 確保各功能規格間的描述一致
|
||||
- **完整性驗證**: 定期檢查是否涵蓋所有必要資訊
|
||||
- **實用性評估**: 根據開發團隊回饋調整規格詳細程度
|
||||
|
||||
## 🎉 成果總結
|
||||
|
||||
### 📊 統計數據
|
||||
- **總頁數**: 約170頁詳細功能規格
|
||||
- **涵蓋UI**: 26個主要畫面 + 17個輔助畫面
|
||||
- **功能模組**: 5個核心功能完整規格
|
||||
- **開發指引**: 前後端和整合的完整技術要求
|
||||
|
||||
### 🏆 預期效益
|
||||
- **減少開發疑問**: 預估減少80%的需求澄清時間
|
||||
- **提升開發效率**: 預估提升40%的開發效率
|
||||
- **降低bug發生率**: 預估減少60%的實現偏差問題
|
||||
- **改善程式品質**: 統一標準提升50%的一致性
|
||||
|
||||
---
|
||||
|
||||
**📝 備註**: 本文檔總覽基於2025-09-08的分析報告建議執行完成。所有功能規格文檔都遵循統一的模板格式,確保文檔品質和實用性。
|
||||
|
||||
**🔗 相關資源**:
|
||||
- **分析報告**: [02_design規格寫法改進需求分析](../../../reports/analysis/2025-09-08_02design規格寫法改進需求分析.md)
|
||||
- **問題記錄**: [ISSUES.md](../../../ISSUES.md) - 02_design規格寫法改進項目
|
||||
- **設計規範**: [ui-ux-guidelines.md](../ui-ux-guidelines.md)
|
||||
- **User Flow**: [user-flow-specification.md](../../04_technical/user-flow-specification.md)
|
||||
|
|
@ -1,224 +0,0 @@
|
|||
# 📚 Web端功能規格文檔總覽
|
||||
|
||||
**建立日期**: 2025-09-09
|
||||
**文檔狀態**: ✅ 已完成Web端核心規格
|
||||
**覆蓋功能**: 5個核心功能模組 (Web版)
|
||||
**對應Mobile規格**: `../mobile/README.md`
|
||||
|
||||
## 📋 Web端規格文檔清單
|
||||
|
||||
### 🌐 已完成的Web端功能規格
|
||||
|
||||
1. **[詞彙學習功能規格_Web.md](./詞彙學習功能規格_Web.md)** ✅ 已完成
|
||||
- 📄 **頁數**: 約45頁詳細規格
|
||||
- 🎯 **核心功能**: 基於Mobile版,增加Web專有功能
|
||||
- 💻 **涉及頁面**: 8個主要頁面 + 1個Web專用分析頁面
|
||||
- 💡 **Web特色**: 快捷鍵系統、多標籤支援、高級統計面板
|
||||
- 🎮 **UI命名**: 統一使用 `Page_*_W` 格式
|
||||
|
||||
2. **[情境對話功能規格_Web.md](./情境對話功能規格_Web.md)** ✅ 已完成
|
||||
- 📄 **頁數**: 約50頁詳細規格
|
||||
- 🎯 **核心功能**: 沉浸式對話練習、多窗格佈局、實時分析
|
||||
- 💻 **涉及頁面**: 6個主要頁面 + 2個Web專用頁面
|
||||
- 💡 **Web特色**: 雙視窗模式、多標籤對話、語音輸入優化
|
||||
- 🔧 **技術特點**: Web Speech API、實時統計、多會話管理
|
||||
|
||||
3. **[學習地圖功能規格_Web.md](./學習地圖功能規格_Web.md)** ✅ 已完成
|
||||
- 📄 **頁數**: 約45頁詳細規格
|
||||
- 🎯 **核心功能**: 可視化學習路徑、進度分析、路徑規劃
|
||||
- 💻 **涉及頁面**: 5個主要頁面 + 3個Web專用頁面
|
||||
- 💡 **Web特色**: 全景地圖視圖、縮放互動、批量操作
|
||||
- 🗺️ **技術特點**: SVG地圖渲染、D3.js圖表、虛擬滾動
|
||||
|
||||
4. **[道具商店功能規格_Web.md](./道具商店功能規格_Web.md)** ✅ 已完成
|
||||
- 📄 **頁數**: 約55頁詳細規格
|
||||
- 🎯 **核心功能**: 電商級購物體驗、訂閱管理、支付整合
|
||||
- 💻 **涉及頁面**: 7個主要頁面 + 4個Web專用頁面
|
||||
- 💡 **Web特色**: 購物車功能、批量購買、價格對比工具
|
||||
- 💳 **技術特點**: 多重支付、PCI DSS合規、A/B測試
|
||||
|
||||
5. **[用戶認證功能規格_Web.md](./用戶認證功能規格_Web.md)** ✅ 已完成
|
||||
- 📄 **頁數**: 約50頁詳細規格
|
||||
- 🎯 **核心功能**: 企業級認證、多設備管理、隱私控制
|
||||
- 💻 **涉及頁面**: 6個主要頁面 + 5個Web專用頁面
|
||||
- 💡 **Web特色**: SSO企業登入、WebAuthn支援、GDPR合規
|
||||
- 🔐 **技術特點**: SAML/OIDC、安全金鑰、隱私合規
|
||||
|
||||
## 🌐 Web端規格特色
|
||||
|
||||
### 📊 Web端規格完整性
|
||||
- **功能對應度**: 與Mobile版85%-100%功能對應
|
||||
- **Web專有功能**: 每個模組都有2-5個Web專屬頁面/功能
|
||||
- **技術深度**: 詳細的Web技術實作指導
|
||||
- **合規要求**: 企業級安全和隱私合規考量
|
||||
|
||||
### 🔧 Web端技術特點
|
||||
- **現代Web標準**: WebAuthn、Web Speech API、WebRTC等
|
||||
- **響應式設計**: 桌面優先的響應式佈局策略
|
||||
- **效能最佳化**: 虛擬滾動、懶載入、Service Worker
|
||||
- **無障礙設計**: WCAG 2.1 AA標準合規
|
||||
|
||||
### 🎮 UI設計系統
|
||||
- **命名規範**: `Page_*_W` (Web頁面) vs `UI_*` (Mobile畫面)
|
||||
- **快捷鍵系統**: 每個功能都有完整的鍵盤快捷鍵
|
||||
- **多螢幕利用**: 充分利用桌面大螢幕空間
|
||||
- **多視窗支援**: 支援多標籤、多視窗的工作流程
|
||||
|
||||
## 🚀 Web端優勢功能
|
||||
|
||||
### 💻 桌面環境特化
|
||||
1. **多工處理**:
|
||||
- 多標籤同時學習
|
||||
- 多視窗對比分析
|
||||
- 拖拽式操作體驗
|
||||
|
||||
2. **深度功能**:
|
||||
- 高級統計分析
|
||||
- 批量數據處理
|
||||
- 複雜篩選和搜索
|
||||
|
||||
3. **專業工具**:
|
||||
- 學習路徑規劃器
|
||||
- 進度對比分析
|
||||
- 數據匯出工具
|
||||
|
||||
### 🔧 企業級功能
|
||||
1. **認證整合**:
|
||||
- SAML/OIDC SSO
|
||||
- LDAP目錄整合
|
||||
- 多重認證支援
|
||||
|
||||
2. **管理功能**:
|
||||
- 批量用戶管理
|
||||
- 學習進度監控
|
||||
- 企業儀表板
|
||||
|
||||
3. **合規支援**:
|
||||
- GDPR資料保護
|
||||
- CCPA隱私合規
|
||||
- PCI DSS支付安全
|
||||
|
||||
## 📈 開發指引差異
|
||||
|
||||
### 🎯 與Mobile版對比
|
||||
|
||||
| 特性 | Mobile端 | Web端 | Web端優勢 |
|
||||
|------|----------|-------|-----------|
|
||||
| UI命名 | `UI_*` | `Page_*_W` | 平台識別清楚 |
|
||||
| 互動方式 | 觸控為主 | 鍵鼠+快捷鍵 | 效率更高 |
|
||||
| 螢幕利用 | 單一焦點 | 多區域並行 | 資訊密度更高 |
|
||||
| 數據展示 | 簡化圖表 | 詳細分析 | 專業度更高 |
|
||||
| 離線功能 | 完整離線 | Service Worker | 技術實現不同 |
|
||||
|
||||
### 🛠️ 技術選型建議
|
||||
|
||||
#### 前端框架選擇
|
||||
- **React生態**: 適合複雜互動和狀態管理
|
||||
- **Vue.js生態**: 適合快速開發和易維護
|
||||
- **Angular**: 適合企業級和大型專案
|
||||
|
||||
#### 技術棧推薦
|
||||
```javascript
|
||||
// 推薦技術組合
|
||||
{
|
||||
"框架": "React/Vue/Angular",
|
||||
"狀態管理": "Redux/Vuex/NgRx",
|
||||
"UI庫": "Ant Design/Element Plus/Angular Material",
|
||||
"圖表": "D3.js + Chart.js",
|
||||
"地圖": "SVG + Canvas",
|
||||
"音頻": "Web Audio API",
|
||||
"認證": "Auth0/Firebase Auth",
|
||||
"支付": "Stripe/PayPal",
|
||||
"分析": "Google Analytics/Mixpanel"
|
||||
}
|
||||
```
|
||||
|
||||
## 🧪 測試策略
|
||||
|
||||
### 🔍 Web專用測試點
|
||||
1. **跨瀏覽器相容性**
|
||||
- Chrome/Firefox/Safari/Edge
|
||||
- 桌面和行動版瀏覽器
|
||||
- 不同作業系統
|
||||
|
||||
2. **響應式設計**
|
||||
- 多解析度適配
|
||||
- 縮放比例測試
|
||||
- 多螢幕配置
|
||||
|
||||
3. **Web API功能**
|
||||
- WebAuthn生物識別
|
||||
- Web Speech語音功能
|
||||
- 通知和權限API
|
||||
|
||||
4. **效能表現**
|
||||
- 大數據量處理
|
||||
- 長時間會話穩定性
|
||||
- 記憶體使用效率
|
||||
|
||||
## 📊 統計數據
|
||||
|
||||
### 📈 Web端規格成果
|
||||
- **總頁數**: 約245頁詳細Web端規格
|
||||
- **涉及頁面**: 32個主要頁面 + 14個Web專用頁面
|
||||
- **功能模組**: 5個核心功能完整Web端規格
|
||||
- **技術指引**: 前端Web開發的完整技術要求
|
||||
|
||||
### 🎯 預期效益
|
||||
- **開發效率提升**: Web端專門化規格提升40%開發效率
|
||||
- **用戶體驗優化**: 桌面環境的專業級使用體驗
|
||||
- **企業市場拓展**: 企業級功能支援B2B市場需求
|
||||
- **技術債務減少**: 平台特化減少50%跨平台適配問題
|
||||
|
||||
## 🔄 維護和更新
|
||||
|
||||
### 📅 更新策略
|
||||
- **功能同步**: 與Mobile版功能保持一致性
|
||||
- **Web特性**: 持續增加Web平台專有功能
|
||||
- **技術跟進**: 跟隨現代Web技術標準更新
|
||||
- **用戶反饋**: 基於桌面用戶使用回饋優化
|
||||
|
||||
### ✅ 品質保證
|
||||
- **規格一致性**: 確保與共同規格和Mobile版的一致性
|
||||
- **技術可行性**: 所有規格都經過技術可行性評估
|
||||
- **用戶體驗**: 遵循Web端最佳實務和設計原則
|
||||
|
||||
## 🔗 相關資源
|
||||
|
||||
### 📚 相關文檔
|
||||
- **Mobile版規格**: [../mobile/](../mobile/) - 對應的Mobile端功能規格
|
||||
- **共同規格**: [../common/](../common/) - 跨平台共同業務邏輯
|
||||
- **平台對應表**: [../平台功能對應表.md](../平台功能對應表.md) - 詳細功能對應關係
|
||||
- **總覽文檔**: [../README.md](../README.md) - 平台化架構總覽
|
||||
|
||||
### 🛠️ 開發資源
|
||||
- **API文檔**: [../common/API規格.md](../common/API規格.md) - 統一API接口
|
||||
- **數據模型**: [../common/數據模型.md](../common/數據模型.md) - 共同數據結構
|
||||
- **業務規則**: [../common/業務規則.md](../common/業務規則.md) - 共同業務邏輯
|
||||
- **Swagger UI**: [/swagger-ui.html](../../../../swagger-ui.html) - 互動式API文檔
|
||||
|
||||
### 🎨 設計資源
|
||||
- **設計規範**: [../../ui-ux-guidelines.md](../../ui-ux-guidelines.md) - UI/UX設計指南
|
||||
- **UI截圖**: [../../views/](../../views/) - 界面設計參考
|
||||
- **品牌指南**: 品牌色彩和字體規範
|
||||
|
||||
---
|
||||
|
||||
**📝 備註**: Web端功能規格基於Mobile端規格擴展,充分利用桌面環境優勢,提供專業級的學習和管理體驗。
|
||||
|
||||
**🎯 使用建議**:
|
||||
- **Web開發團隊**: 主要參考此目錄的規格文檔
|
||||
- **產品經理**: 使用平台對應表了解功能差異
|
||||
- **設計師**: 注意Web端特有的交互設計模式
|
||||
- **測試工程師**: 重點測試Web平台專有功能
|
||||
|
||||
**🚀 下一步**:
|
||||
- 完善剩餘功能的Web端規格
|
||||
- 建立Web端原型和設計系統
|
||||
- 制定Web端開發和測試計劃
|
||||
|
||||
---
|
||||
|
||||
**最後更新**: 2025-09-09
|
||||
**版本**: v1.0
|
||||
**維護者**: Drama Ling Web開發團隊
|
||||
|
|
@ -1,235 +0,0 @@
|
|||
# [功能名稱]功能規格文檔 (Web版)
|
||||
|
||||
## 📋 功能概述
|
||||
|
||||
**功能名稱**: [功能名稱] (Web端)
|
||||
**建立日期**: [日期]
|
||||
**最後更新**: [日期]
|
||||
**負責團隊**: 前端Web/設計/開發
|
||||
**對應Mobile規格**: `../mobile/[對應的Mobile規格文檔].md`
|
||||
|
||||
### 主要功能
|
||||
- [主要功能1]
|
||||
- [主要功能2]
|
||||
- [主要功能3]
|
||||
|
||||
### Web端特色功能
|
||||
- **[Web特色1]**: [詳細說明Web端特有功能]
|
||||
- **[Web特色2]**: [如:快捷鍵系統、多標籤支援等]
|
||||
- **[Web特色3]**: [如:大螢幕優化、批量操作等]
|
||||
|
||||
### 適用場景
|
||||
- [桌面環境的特殊使用場景1]
|
||||
- [需要大螢幕/鍵鼠操作的場景2]
|
||||
- [企業/教育環境的場景3]
|
||||
- [長時間深度使用的場景4]
|
||||
|
||||
### 與其他功能的關聯
|
||||
- **[相關功能1]**: [關聯性說明]
|
||||
- **[相關功能2]**: [關聯性說明]
|
||||
|
||||
## 💻 涉及的Web頁面
|
||||
|
||||
### 主要頁面
|
||||
1. **Page_[頁面名稱]_W** - [頁面用途] (Web版)
|
||||
2. **Page_[頁面名稱]_W** - [頁面用途] (Web版)
|
||||
|
||||
### Web專用頁面
|
||||
1. **Page_[Web專用頁面]_W** - [Web專有功能頁面] (Web專用)
|
||||
2. **Page_[Web專用頁面]_W** - [Web專有功能頁面] (Web專用)
|
||||
|
||||
### 輔助畫面
|
||||
1. **Modal_[模態視窗名稱]_W** - [模態視窗用途]
|
||||
2. **Panel_[面板名稱]_W** - [面板用途]
|
||||
|
||||
## 🎯 詳細頁面規格
|
||||
|
||||
### Page_[頁面名稱]_W - [頁面標題] (Web版)
|
||||
|
||||
#### 功能說明
|
||||
- **頁面目的**: [說明此頁面在桌面環境中的主要用途]
|
||||
- **進入條件**: [用戶如何進入此頁面,包含URL路由]
|
||||
- **退出條件**: [用戶如何離開此頁面]
|
||||
|
||||
#### Web版佈局特點
|
||||
- **主要區域**: [占螢幕百分比,主要內容顯示]
|
||||
- **側邊欄**: [如有,說明側邊欄功能和位置]
|
||||
- **工具列**: [頂部/底部工具列的功能]
|
||||
- **響應式**: [不同螢幕尺寸的佈局變化]
|
||||
|
||||
#### 頁面欄位細節
|
||||
|
||||
| 欄位名稱 | 資料類型 | 必填 | 預設值 | 驗證規則 | 顯示條件 |
|
||||
|---------|---------|------|--------|----------|----------|
|
||||
| [欄位1] | [類型] | 是/否 | [預設值] | [驗證規則] | [條件] |
|
||||
| [欄位2] | [類型] | 是/否 | [預設值] | [驗證規則] | [條件] |
|
||||
|
||||
#### Web版互動元素
|
||||
|
||||
| 元素名稱 | 元素類型 | 操作方式 | 快捷鍵 | 狀態變化 | 備註 |
|
||||
|---------|---------|----------|--------|----------|------|
|
||||
| [按鈕1] | 按鈕 | 點擊/快捷鍵 | [快捷鍵] | [狀態改變] | [Web特殊說明] |
|
||||
| [輸入框1] | 文本框 | 點擊/Tab導航 | [快捷鍵] | [狀態改變] | [鍵盤操作說明] |
|
||||
| [下拉選單1] | 選單 | 點擊/方向鍵 | [快捷鍵] | [狀態改變] | [鍵盤導航] |
|
||||
|
||||
#### Web版使用者操作流程
|
||||
1. **步驟1**: [用戶操作] → [系統反應] → [結果] (支援快捷鍵: [鍵])
|
||||
2. **步驟2**: [用戶操作] → [系統反應] → [結果] (拖拽/批量操作)
|
||||
3. **步驟3**: [用戶操作] → [系統反應] → [結果] (多視窗/標籤切換)
|
||||
|
||||
#### 異常狀況處理
|
||||
- **情況1**: [Web特有異常] → [處理方式] → [用戶看到的結果]
|
||||
- **情況2**: [瀏覽器相容性問題] → [處理方式] → [降級方案]
|
||||
- **情況3**: [網路中斷] → [離線處理] → [同步恢復]
|
||||
|
||||
### Page_[Web專用頁面]_W - [Web專用功能] (Web專用)
|
||||
|
||||
#### 功能說明
|
||||
- **頁面目的**: [Web端專有功能的說明,為什麼Mobile端沒有]
|
||||
- **進入條件**: [從哪些入口進入此專用功能]
|
||||
- **退出條件**: [如何退出或完成操作]
|
||||
|
||||
#### Web專有功能
|
||||
- **[專有功能1]**: [詳細說明為什麼Web端獨有]
|
||||
- **[專有功能2]**: [技術實現和用戶價值]
|
||||
- **[專有功能3]**: [與Mobile端的差異化優勢]
|
||||
|
||||
## 🌐 Web端技術特點
|
||||
|
||||
### 響應式設計
|
||||
- **桌面優先**: [大螢幕最佳化設計]
|
||||
- **平板適配**: [平板橫向模式的適配]
|
||||
- **縮放支援**: [瀏覽器縮放的處理]
|
||||
- **多螢幕**: [多顯示器環境的支援]
|
||||
|
||||
### Web API整合
|
||||
- **[Web API 1]**: [如 Web Speech API,使用場景]
|
||||
- **[Web API 2]**: [如 WebAuthn,安全認證]
|
||||
- **[Web API 3]**: [如 Clipboard API,複製貼上]
|
||||
- **[Web API 4]**: [如 Fullscreen API,全螢幕模式]
|
||||
|
||||
### 效能最佳化
|
||||
- **[最佳化策略1]**: [如虛擬滾動,處理大數據]
|
||||
- **[最佳化策略2]**: [如懶載入,提升載入速度]
|
||||
- **[最佳化策略3]**: [如Service Worker,離線支援]
|
||||
- **[最佳化策略4]**: [如CDN,靜態資源加速]
|
||||
|
||||
## ⌨️ Web版快捷鍵系統
|
||||
|
||||
### 通用快捷鍵
|
||||
- `Ctrl + S` - [功能描述]
|
||||
- `Ctrl + Z` - [功能描述]
|
||||
- `Ctrl + Y` - [功能描述]
|
||||
- `F11` - [全螢幕模式]
|
||||
- `Esc` - [取消/返回]
|
||||
|
||||
### 頁面專用快捷鍵
|
||||
- `[自訂鍵]` - [頁面特有功能]
|
||||
- `[組合鍵]` - [複雜操作]
|
||||
- `Tab` - [焦點移動]
|
||||
- `Enter` - [確認操作]
|
||||
|
||||
### 輔助功能快捷鍵
|
||||
- `Ctrl + +/-` - [縮放控制]
|
||||
- `Ctrl + F` - [搜索功能]
|
||||
- `Ctrl + P` - [列印功能]
|
||||
- `F1` - [幫助說明]
|
||||
|
||||
## 📊 Web版業務邏輯差異
|
||||
|
||||
### [業務邏輯分類1]
|
||||
- **Mobile版**: [Mobile端的邏輯處理]
|
||||
- **Web版**: [Web端的增強邏輯]
|
||||
- **差異原因**: [為什麼需要不同處理]
|
||||
|
||||
### [業務邏輯分類2]
|
||||
- **批量操作**: [Web端特有的批量處理邏輯]
|
||||
- **多會話管理**: [多標籤/視窗的狀態管理]
|
||||
- **資料同步**: [跨標籤的資料同步機制]
|
||||
|
||||
### [業務邏輯分類3]
|
||||
- **權限控制**: [企業環境的權限差異]
|
||||
- **合規要求**: [GDPR等Web特有合規需求]
|
||||
- **安全增強**: [Web端額外的安全措施]
|
||||
|
||||
## 🧪 Web版測試要點
|
||||
|
||||
### 瀏覽器相容性測試
|
||||
- [ ] Chrome [版本]+ 功能完整性
|
||||
- [ ] Firefox [版本]+ 功能完整性
|
||||
- [ ] Safari [版本]+ 功能完整性
|
||||
- [ ] Edge [版本]+ 功能完整性
|
||||
|
||||
### 響應式測試
|
||||
- [ ] 1920x1080 桌面解析度
|
||||
- [ ] 1366x768 筆電解析度
|
||||
- [ ] 1024x768 平板橫向
|
||||
- [ ] 瀏覽器縮放 50%-200%
|
||||
|
||||
### Web特有功能測試
|
||||
- [ ] 快捷鍵系統完整性
|
||||
- [ ] 拖拽操作準確性
|
||||
- [ ] 多標籤狀態同步
|
||||
- [ ] 長時間會話穩定性
|
||||
|
||||
### 效能測試
|
||||
- [ ] 頁面載入時間 < [秒數]
|
||||
- [ ] 大數據渲染流暢度
|
||||
- [ ] 記憶體使用合理範圍
|
||||
- [ ] CPU使用率正常
|
||||
|
||||
### 無障礙測試
|
||||
- [ ] 鍵盤導航完整
|
||||
- [ ] 螢幕閱讀器支援
|
||||
- [ ] 色彩對比度符合標準
|
||||
- [ ] 焦點指示清楚可見
|
||||
|
||||
## 📝 Web端開發注意事項
|
||||
|
||||
### 前端開發
|
||||
- **框架選擇**: [推薦的前端框架,如React/Vue/Angular]
|
||||
- **狀態管理**: [推薦的狀態管理方案]
|
||||
- **UI庫**: [推薦的UI元件庫]
|
||||
- **打包工具**: [Webpack/Vite等工具配置]
|
||||
|
||||
### 瀏覽器相容
|
||||
- **ES6+語法**: [現代JS語法的使用策略]
|
||||
- **CSS Grid/Flexbox**: [現代CSS佈局的使用]
|
||||
- **Polyfill**: [舊瀏覽器的支援策略]
|
||||
- **Progressive Enhancement**: [漸進式增強策略]
|
||||
|
||||
### 效能考量
|
||||
- **首屏載入**: [關鍵指標和最佳化策略]
|
||||
- **代碼分割**: [動態導入和懶載入]
|
||||
- **圖片最佳化**: [WebP格式和響應式圖片]
|
||||
- **快取策略**: [瀏覽器快取和CDN配置]
|
||||
|
||||
### 使用者體驗
|
||||
- **載入狀態**: [載入中的視覺回饋]
|
||||
- **錯誤處理**: [友善的錯誤訊息和恢復指引]
|
||||
- **離線體驗**: [Service Worker離線策略]
|
||||
- **無障礙設計**: [WCAG 2.1標準遵循]
|
||||
|
||||
## 📚 參考資源
|
||||
|
||||
- **對應Mobile規格**: `../mobile/[對應文檔].md` - 功能對照參考
|
||||
- **共同業務邏輯**: `../common/業務規則.md` - 跨平台共同規則
|
||||
- **數據模型**: `../common/數據模型.md` - 統一數據結構
|
||||
- **API規格**: `../common/API規格.md` - 統一API接口
|
||||
- **平台對應表**: `../平台功能對應表.md` - 功能對應關係
|
||||
- **UI截圖**: `docs/02_design/views/web/Page_[相關頁面]_W.png`
|
||||
- **設計規範**: `docs/02_design/ui-ux-guidelines.md`
|
||||
- **技術文檔**: `docs/04_technical/web/[相關技術文檔].md`
|
||||
|
||||
## 📅 版本歷史
|
||||
|
||||
| 版本 | 日期 | 修改內容 | 修改者 |
|
||||
|-----|------|----------|--------|
|
||||
| v1.0 | [日期] | 初始版本建立,基於Mobile版規格擴展 | [姓名] |
|
||||
|
||||
---
|
||||
|
||||
**文檔狀態**: 🟡 進行中 / 🟢 已完成 / 🔴 需要修訂
|
||||
**最後檢查**: [日期]
|
||||
**下次檢查**: [日期]
|
||||
**對應Mobile版本**: [Mobile規格版本號]
|
||||
|
|
@ -1,282 +0,0 @@
|
|||
# 學習地圖功能規格文檔 (Web版)
|
||||
|
||||
## 📋 功能概述
|
||||
|
||||
**功能名稱**: 學習地圖系統 (Web端)
|
||||
**建立日期**: 2025-09-09
|
||||
**最後更新**: 2025-09-09
|
||||
**負責團隊**: 前端Web/設計/開發
|
||||
**對應Mobile規格**: `../mobile/03_學習地圖功能規格.md`
|
||||
|
||||
### 主要功能
|
||||
- 階段化學習路徑,從基礎到進階的順序解鎖機制
|
||||
- 可視化進度追蹤,清晰展示學習成就和剩餘目標
|
||||
- 多元化關卡類型,包含對話、詞彙、語法、聽力等訓練
|
||||
- 個人化學習建議,基於用戶表現調整學習路徑
|
||||
- 社交競爭元素,好友進度對比和排行榜功能
|
||||
|
||||
### Web端特色功能
|
||||
- **全景地圖視圖**: 利用大螢幕展示完整學習路徑
|
||||
- **縮放互動地圖**: 支援滑鼠縮放和拖拽導航
|
||||
- **多層級檢視**: 可切換總覽、階段、詳細三個層級
|
||||
- **進度對比分析**: 圖表化顯示學習進度和預期目標
|
||||
- **批量操作**: 可同時規劃多個學習目標
|
||||
- **學習路徑客製化**: 用戶可自訂學習順序和重點
|
||||
|
||||
### 適用場景
|
||||
- 桌面環境的學習規劃和進度管理
|
||||
- 需要整體學習策略規劃的深度用戶
|
||||
- 教師或家長監督學習進度
|
||||
- 長期學習目標的追蹤和調整
|
||||
|
||||
### 與其他功能的關聯
|
||||
- **詞彙學習系統**: 根據地圖進度解鎖詞彙包
|
||||
- **對話練習系統**: 按階段開放對話情境
|
||||
- **成就系統**: 完成關卡獲得徽章和獎勵
|
||||
- **道具商店**: 使用道具加速解鎖或重置進度
|
||||
- **分析系統**: 學習軌跡數據用於個性化建議
|
||||
|
||||
## 💻 涉及的Web頁面
|
||||
|
||||
### 主要頁面
|
||||
1. **Page_Map_Overview_W** - 學習地圖總覽頁面 (Web版)
|
||||
2. **Page_Map_Stage_Details_W** - 階段詳情與規劃頁面 (Web版)
|
||||
3. **Page_Map_Level_Details_W** - 關卡詳情頁面 (Web版)
|
||||
4. **Page_Map_Progress_Analytics_W** - 進度分析儀表板 (Web版)
|
||||
5. **Page_Achievement_Gallery_W** - 成就展示廳 (Web版)
|
||||
|
||||
### Web專用頁面
|
||||
1. **Page_Learning_Path_Planner_W** - 學習路徑規劃器 (Web專用)
|
||||
2. **Page_Progress_Comparison_W** - 進度對比分析頁面 (Web專用)
|
||||
3. **Page_Study_Schedule_Manager_W** - 學習排程管理器 (Web專用)
|
||||
|
||||
### 輔助畫面
|
||||
1. **Modal_Level_Preview_W** - 關卡預覽模態視窗
|
||||
2. **Modal_Achievement_Details_W** - 成就詳情模態視窗
|
||||
3. **Panel_Quick_Progress_W** - 快速進度面板
|
||||
4. **Tooltip_Level_Info_W** - 關卡資訊提示框
|
||||
|
||||
## 🎯 詳細頁面規格
|
||||
|
||||
### Page_Map_Overview_W - 學習地圖總覽頁面 (Web版)
|
||||
|
||||
#### 功能說明
|
||||
- **頁面目的**: 在大螢幕上展示完整的學習地圖,提供直觀的進度概覽
|
||||
- **進入條件**: 從主選單進入或設為用戶主頁
|
||||
- **退出條件**: 進入具體關卡或其他功能模組
|
||||
|
||||
#### Web版佈局特點
|
||||
- **地圖主區域**: 占螢幕75%,支援縮放和拖拽的互動地圖
|
||||
- **進度側邊欄**: 右側25%,顯示整體統計和快速導航
|
||||
- **頂部工具列**: 包含視圖切換、搜索、篩選等功能
|
||||
- **底部狀態列**: 顯示當前學習狀態和下一個建議目標
|
||||
|
||||
#### 頁面欄位細節
|
||||
|
||||
| 欄位名稱 | 資料類型 | 必填 | 預設值 | 驗證規則 | 顯示條件 |
|
||||
|---------|---------|------|--------|----------|----------|
|
||||
| 學習階段列表 | Array | 是 | [] | 階段陣列 | 地圖主區域顯示 |
|
||||
| 當前學習階段 | Number | 是 | 1 | 1-13階段 | 高亮顯示 |
|
||||
| 整體完成進度 | Number | 是 | 0 | 0-100% | 進度條顯示 |
|
||||
| 已完成關卡數 | Number | 是 | 0 | >=0 | 統計卡片顯示 |
|
||||
| 已解鎖關卡數 | Number | 是 | 1 | >=1 | 統計卡片顯示 |
|
||||
| 總獲得星級 | Number | 是 | 0 | >=0 | 星級統計顯示 |
|
||||
| 近期學習活動 | Array | 是 | [] | 活動陣列 | 側邊欄時間軸 |
|
||||
| 推薦下一步 | Object | 否 | null | 建議物件 | 底部建議區域 |
|
||||
| 地圖縮放級別 | Number | 是 | 1.0 | 0.5-3.0 | 控制地圖顯示範圍 |
|
||||
| 篩選條件 | Object | 否 | {} | 篩選物件 | 頂部篩選器 |
|
||||
|
||||
#### Web版互動元素
|
||||
|
||||
| 元素名稱 | 元素類型 | 操作方式 | 快捷鍵 | 狀態變化 | 備註 |
|
||||
|---------|---------|----------|--------|----------|------|
|
||||
| 地圖縮放控制 | 按鈕組+滾輪 | 滑鼠滾輪/+- | +/- | 0.5x-3x縮放 | 支援滑鼠縮放 |
|
||||
| 地圖拖拽 | 拖拽區域 | 滑鼠拖拽 | 方向鍵 | 移動視窗 | 支援鍵盤導航 |
|
||||
| 關卡點擊 | 地圖節點 | 點擊/Enter | Enter | 正常→選中 | 顯示關卡詳情 |
|
||||
| 階段跳轉 | 導航按鈕 | 點擊/數字鍵 | 1-9 | - | 快速跳轉到階段 |
|
||||
| 視圖切換 | 標籤組 | 點擊/Tab | Tab | 總覽↔詳細 | 切換顯示模式 |
|
||||
| 搜索關卡 | 搜索框 | 輸入/Ctrl+F | Ctrl+F | - | 關卡名稱搜索 |
|
||||
| 進度篩選 | 下拉選單 | 點擊/F | F | 全部→篩選 | 按完成狀態篩選 |
|
||||
| 全螢幕地圖 | 按鈕 | 點擊/F11 | F11 | 正常↔全螢幕 | 沉浸式地圖檢視 |
|
||||
| 重置視圖 | 按鈕 | 點擊/R | R | - | 恢復預設視圖 |
|
||||
|
||||
#### Web版使用者操作流程
|
||||
1. **地圖瀏覽**: 頁面載入 → 查看整體進度 → 使用縮放和拖拽探索地圖
|
||||
2. **關卡選擇**: 點擊關卡節點 → 預覽關卡資訊 → 決定開始學習或跳過
|
||||
3. **進度規劃**: 查看推薦路徑 → 設定學習目標 → 規劃學習排程
|
||||
4. **成就查看**: 瀏覽已獲得成就 → 查看未完成挑戰 → 設定新的成就目標
|
||||
|
||||
### Page_Learning_Path_Planner_W - 學習路徑規劃器 (Web專用)
|
||||
|
||||
#### 功能說明
|
||||
- **頁面目的**: 允許用戶客製化學習路徑,規劃個人化學習計畫
|
||||
- **進入條件**: 從地圖總覽點擊"規劃學習"或快捷鍵Ctrl+P
|
||||
- **退出條件**: 保存學習計畫或取消規劃
|
||||
|
||||
#### Web專有功能
|
||||
- **拖拽式規劃**: 可拖拽關卡重新排序學習順序
|
||||
- **時間估算**: 自動計算完成規劃所需的學習時間
|
||||
- **衝突檢測**: 自動檢測學習計畫中的前置條件衝突
|
||||
- **多計畫管理**: 可建立多個學習計畫並比較選擇
|
||||
|
||||
#### 頁面欄位細節
|
||||
|
||||
| 欄位名稱 | 資料類型 | 必填 | 預設值 | 驗證規則 | 顯示條件 |
|
||||
|---------|---------|------|--------|----------|----------|
|
||||
| 計畫名稱 | String | 是 | "我的學習計畫" | 1-50字 | 頁面頂部輸入框 |
|
||||
| 目標完成時間 | Date | 否 | - | 未來日期 | 日期選擇器 |
|
||||
| 每日學習時間 | Number | 是 | 30 | 15-180分鐘 | 滑桿選擇器 |
|
||||
| 選中關卡列表 | Array | 是 | [] | 關卡ID陣列 | 中央規劃區域 |
|
||||
| 學習優先級 | Object | 是 | {} | 優先級設定 | 關卡屬性設定 |
|
||||
| 預計完成時間 | Number | 是 | 0 | >=0天 | 自動計算顯示 |
|
||||
| 衝突警告 | Array | 否 | [] | 衝突陣列 | 警告提示區域 |
|
||||
| 計畫狀態 | String | 是 | "草稿" | 狀態枚舉 | 狀態指示器 |
|
||||
|
||||
### Page_Progress_Comparison_W - 進度對比分析頁面 (Web專用)
|
||||
|
||||
#### 功能說明
|
||||
- **頁面目的**: 提供詳細的學習進度分析,包含歷史對比和預測
|
||||
- **進入條件**: 從進度統計點擊"詳細分析"或快捷鍵Ctrl+A
|
||||
- **退出條件**: 返回地圖或關閉分析頁面
|
||||
|
||||
#### Web專有分析功能
|
||||
- **時間序列分析**: 學習進度的時間變化趨勢
|
||||
- **能力雷達圖**: 不同技能領域的平衡發展分析
|
||||
- **目標達成預測**: 基於當前進度預測目標完成時間
|
||||
- **同儕比較**: 與相似程度學習者的進度對比
|
||||
|
||||
#### 頁面欄位細節
|
||||
|
||||
| 欄位名稱 | 資料類型 | 必填 | 預設值 | 驗證規則 | 顯示條件 |
|
||||
|---------|---------|------|--------|----------|----------|
|
||||
| 分析時間範圍 | DateRange | 是 | 最近3個月 | 有效範圍 | 時間選擇器 |
|
||||
| 進度趨勢數據 | Array | 是 | [] | 時間序列 | 折線圖顯示 |
|
||||
| 技能平衡分析 | Object | 是 | {} | 技能評分 | 雷達圖顯示 |
|
||||
| 學習效率指標 | Number | 是 | 0 | 0-100 | 效率評分卡片 |
|
||||
| 目標達成預測 | Object | 否 | null | 預測數據 | 預測圖表 |
|
||||
| 同儕排名 | Number | 否 | - | 排名數字 | 排名顯示器 |
|
||||
| 學習建議 | Array | 是 | [] | 建議陣列 | 建議列表 |
|
||||
|
||||
## 🌐 Web端技術特點
|
||||
|
||||
### 互動地圖技術
|
||||
- **SVG地圖渲染**: 使用SVG繪製可縮放的學習地圖
|
||||
- **Canvas效能優化**: 大量節點使用Canvas渲染提升效能
|
||||
- **虛擬化滾動**: 大型地圖的效能最佳化
|
||||
- **響應式縮放**: 根據螢幕大小自動調整地圖比例
|
||||
|
||||
### 資料視覺化
|
||||
- **D3.js圖表**: 進度分析的豐富圖表支援
|
||||
- **Chart.js整合**: 簡單統計圖表的快速實現
|
||||
- **動畫過渡**: 流暢的數據變化動畫效果
|
||||
- **互動式圖表**: 可點擊、縮放、篩選的圖表
|
||||
|
||||
### 本地存儲最佳化
|
||||
- **IndexedDB**: 離線地圖數據和進度快取
|
||||
- **LocalStorage**: 用戶偏好設定和視圖狀態
|
||||
- **SessionStorage**: 臨時學習計畫和操作記錄
|
||||
- **WebSQL備援**: 舊瀏覽器的資料存儲支援
|
||||
|
||||
## ⌨️ Web版快捷鍵系統
|
||||
|
||||
### 地圖導航快捷鍵
|
||||
- `↑↓←→` - 地圖移動
|
||||
- `+/-` - 地圖縮放
|
||||
- `R` - 重置視圖
|
||||
- `F11` - 全螢幕模式
|
||||
- `Space` - 回到當前學習位置
|
||||
- `Home` - 回到地圖起始位置
|
||||
- `End` - 跳到最遠解鎖位置
|
||||
|
||||
### 關卡操作快捷鍵
|
||||
- `Enter` - 開始選中的關卡
|
||||
- `Tab` - 切換到下一個可用關卡
|
||||
- `Shift + Tab` - 切換到上一個關卡
|
||||
- `1-9` - 跳轉到對應階段
|
||||
- `Ctrl + Click` - 多選關卡(規劃模式)
|
||||
|
||||
### 功能操作快捷鍵
|
||||
- `Ctrl + F` - 搜索關卡
|
||||
- `Ctrl + P` - 開啟學習規劃器
|
||||
- `Ctrl + A` - 開啟進度分析
|
||||
- `Ctrl + S` - 保存當前計畫
|
||||
- `F` - 開啟篩選選項
|
||||
- `H` - 顯示/隱藏幫助信息
|
||||
|
||||
## 📊 Web版業務邏輯差異
|
||||
|
||||
### 進度管理增強
|
||||
- **批量操作**: 可同時重置或重新挑戰多個關卡
|
||||
- **智慧推薦**: 基於桌面用戶學習模式的個性化推薦
|
||||
- **長期規劃**: 支援週期性和長期學習目標設定
|
||||
- **詳細統計**: 比Mobile版更豐富的進度分析
|
||||
|
||||
### 社交功能擴展
|
||||
- **學習小組**: 可建立或加入學習小組
|
||||
- **進度分享**: 一鍵分享學習成就到社交媒體
|
||||
- **競賽功能**: 參與線上學習競賽和挑戰
|
||||
- **導師模式**: 高級用戶可指導新用戶
|
||||
|
||||
### 客製化功能
|
||||
- **佈景主題**: 多種地圖主題和顏色方案
|
||||
- **顯示偏好**: 可調整地圖元素的顯示方式
|
||||
- **通知設定**: 靈活的學習提醒和成就通知
|
||||
- **資料匯出**: 學習進度和統計的多格式匯出
|
||||
|
||||
## 🧪 Web版測試要點
|
||||
|
||||
### 地圖互動測試
|
||||
- [ ] 地圖縮放流暢度測試 (0.5x-3x)
|
||||
- [ ] 大型地圖拖拽效能測試
|
||||
- [ ] 關卡點擊響應速度測試
|
||||
- [ ] 多關卡選擇準確性測試
|
||||
- [ ] 快捷鍵功能完整性測試
|
||||
|
||||
### 視覺化效能測試
|
||||
- [ ] 大量數據點圖表渲染測試
|
||||
- [ ] 動畫過渡流暢度測試
|
||||
- [ ] 多圖表同時顯示效能測試
|
||||
- [ ] 響應式佈局適配測試
|
||||
|
||||
### 資料同步測試
|
||||
- [ ] 離線模式功能完整性測試
|
||||
- [ ] 多標籤頁資料同步測試
|
||||
- [ ] 學習進度即時更新測試
|
||||
- [ ] 成就解鎖通知準確性測試
|
||||
|
||||
### 瀏覽器相容性測試
|
||||
- [ ] Chrome SVG渲染正確性
|
||||
- [ ] Firefox Canvas效能表現
|
||||
- [ ] Safari 動畫效果流暢度
|
||||
- [ ] Edge 資料存儲功能正常
|
||||
|
||||
## 📝 Web端開發注意事項
|
||||
|
||||
### 前端技術選型
|
||||
- 地圖渲染使用SVG + Canvas混合方案
|
||||
- 狀態管理使用Redux/MobX處理複雜地圖狀態
|
||||
- 動畫使用Framer Motion或React Spring
|
||||
- 圖表使用D3.js + Chart.js組合方案
|
||||
|
||||
### 效能最佳化策略
|
||||
- 地圖節點使用虛擬化渲染
|
||||
- 大型數據集使用Web Workers處理
|
||||
- 圖片資源使用WebP格式和懶載入
|
||||
- 關卡數據使用增量載入策略
|
||||
|
||||
### 使用者體驗設計
|
||||
- 提供地圖使用教學和引導
|
||||
- 支援無障礙設備和鍵盤導航
|
||||
- 提供多種視圖模式適應不同用戶
|
||||
- 實現智慧的學習路徑推薦系統
|
||||
|
||||
---
|
||||
|
||||
**文檔狀態**: 🟢 已完成
|
||||
**最後更新**: 2025-09-09
|
||||
**版本**: v1.0
|
||||
**相關文檔**:
|
||||
- `../mobile/03_學習地圖功能規格.md` - 對應的Mobile版規格
|
||||
- `../common/業務規則.md` - 共同業務邏輯
|
||||
- `../common/數據模型.md` - 數據結構定義
|
||||
- `../common/API規格.md` - API接口規格
|
||||
|
|
@ -1,286 +0,0 @@
|
|||
# 情境對話功能規格文檔 (Web版)
|
||||
|
||||
## 📋 功能概述
|
||||
|
||||
**功能名稱**: 情境對話訓練系統 (Web端)
|
||||
**建立日期**: 2025-09-09
|
||||
**最後更新**: 2025-09-09
|
||||
**負責團隊**: 前端Web/設計/開發
|
||||
**對應Mobile規格**: `../mobile/01_情境對話功能規格.md`
|
||||
|
||||
### 主要功能
|
||||
- 沉浸式情境對話練習,支援多場景劇本
|
||||
- 任務導向對話訓練,完成指定溝通目標
|
||||
- 限時對話挑戰,提升反應速度和流暢度
|
||||
- AI即時分析回饋,提供個人化學習建議
|
||||
- 三維度評分系統,全面評估學習成效
|
||||
- 對話訂正功能,精準糾正語法和表達問題
|
||||
|
||||
### Web端特色功能
|
||||
- **雙視窗模式**: 左右分割顯示對話和輔助資訊
|
||||
- **實時打字支援**: 完整鍵盤輸入,支援多語言輸入法
|
||||
- **對話歷史面板**: 可捲動查看完整對話記錄
|
||||
- **多標籤對話**: 同時進行多個對話練習
|
||||
- **語音輸入優化**: Web Speech API整合,桌面級語音識別
|
||||
- **進階統計儀表板**: 詳細的對話分析和學習軌跡
|
||||
|
||||
### 適用場景
|
||||
- 辦公室桌面環境的深度對話練習
|
||||
- 需要大量文字輸入的商務溝通訓練
|
||||
- 多螢幕環境下的沉浸式學習體驗
|
||||
- 長時間專注的對話技能提升訓練
|
||||
|
||||
### 與其他功能的關聯
|
||||
- **詞彙學習系統**: 整合指定詞彙到對話情境中
|
||||
- **學習地圖系統**: 提供情境對話的關卡和進度管理
|
||||
- **道具商店系統**: 回覆提示道具、加時道具的商業整合
|
||||
- **命條系統**: 對話失敗消耗命條的生命管理機制
|
||||
- **排行榜系統**: 限時挑戰成績和社交競爭功能
|
||||
|
||||
## 💻 涉及的Web頁面
|
||||
|
||||
### 主要頁面
|
||||
1. **Page_Dialogue_Main_W** - 情境對話主界面 (Web版)
|
||||
2. **Page_Dialogue_Analysis_W** - AI對話分析頁面 (Web版)
|
||||
3. **Page_Character_Details_W** - 角色詳情與背景介紹 (Web版)
|
||||
4. **Page_Keywords_Details_W** - 情境關鍵詞預習頁面 (Web版)
|
||||
5. **Page_Reply_Input_W** - 進階回覆輸入系統 (Web版)
|
||||
6. **Page_Reply_Assistance_W** - 回覆卡關輔助面板 (Web版)
|
||||
|
||||
### Web專用頁面
|
||||
1. **Page_Dialogue_History_Dashboard_W** - 對話歷史統計儀表板 (Web專用)
|
||||
2. **Page_Multi_Dialogue_Manager_W** - 多對話管理頁面 (Web專用)
|
||||
|
||||
### 輔助畫面
|
||||
1. **Modal_Cost_Confirm_W** - 對話成本確認模態視窗
|
||||
2. **Modal_TimeWarp_Cards_W** - 時光卷道具使用模態視窗
|
||||
3. **Modal_Challenge_Exit_Confirm_W** - 挑戰退出確認對話框
|
||||
4. **Panel_Task_Display_W** - 任務完成狀態顯示面板
|
||||
|
||||
## 🎯 詳細頁面規格
|
||||
|
||||
### Page_Dialogue_Main_W - 情境對話主界面 (Web版)
|
||||
|
||||
#### 功能說明
|
||||
- **頁面目的**: 在桌面環境中提供沉浸式的對話練習體驗
|
||||
- **進入條件**: 從學習地圖選擇對話關卡,或從瀏覽器書籤進入
|
||||
- **退出條件**: 完成對話或主動離開頁面
|
||||
|
||||
#### Web版佈局特點
|
||||
- **主要對話區域**: 占螢幕60%寬度,中央對話泡泡顯示
|
||||
- **角色資訊面板**: 右側20%寬度,顯示角色背景和情境
|
||||
- **輔助功能面板**: 左側20%寬度,顯示提示、關鍵詞、任務進度
|
||||
- **底部輸入區域**: 固定在底部,包含文字輸入和語音輸入
|
||||
|
||||
#### 頁面欄位細節
|
||||
|
||||
| 欄位名稱 | 資料類型 | 必填 | 預設值 | 驗證規則 | 顯示條件 |
|
||||
|---------|---------|------|--------|----------|----------|
|
||||
| 對話情境標題 | String | 是 | - | 5-50字 | 頁面頂部顯示 |
|
||||
| 對話參與角色 | Array | 是 | - | 角色陣列 | 右側面板顯示 |
|
||||
| 對話歷史記錄 | Array | 是 | [] | 對話陣列 | 中央區域,可捲動 |
|
||||
| 當前說話角色 | String | 是 | - | 角色ID | 動態高亮顯示 |
|
||||
| 用戶回覆輸入 | String | 否 | - | 1-500字 | 底部輸入框 |
|
||||
| 任務進度指示 | Number | 是 | 0 | 0-100% | 左側面板進度條 |
|
||||
| 剩餘時間 | Number | 否 | - | >=0秒 | 限時挑戰時顯示 |
|
||||
| 關鍵詞提示 | Array | 否 | [] | 詞彙陣列 | 左側面板列表 |
|
||||
| 語音輸入狀態 | Boolean | 是 | false | true/false | 麥克風圖示狀態 |
|
||||
| 對話品質評分 | Object | 否 | null | 評分物件 | 實時評分顯示 |
|
||||
|
||||
#### Web版互動元素
|
||||
|
||||
| 元素名稱 | 元素類型 | 操作方式 | 快捷鍵 | 狀態變化 | 備註 |
|
||||
|---------|---------|----------|--------|----------|------|
|
||||
| 文字輸入框 | 文本區域 | 點擊/自動焦點 | - | 空白→輸入中 | 支援多語言輸入法 |
|
||||
| 語音輸入按鈕 | 按鈕 | 點擊/V鍵 | V | 關閉→錄音中 | Web Speech API |
|
||||
| 送出回覆按鈕 | 按鈕 | 點擊/Enter鍵 | Enter | 正常→傳送中 | 主要操作按鈕 |
|
||||
| 提示請求按鈕 | 按鈕 | 點擊/H鍵 | H | 正常→請求中 | 消耗道具提示 |
|
||||
| 角色背景查看 | 面板 | 滑鼠懸停 | I | 收合→展開 | 右側面板互動 |
|
||||
| 對話歷史捲動 | 捲動條 | 滑鼠滾輪 | ↑↓ | - | 無限捲動對話記錄 |
|
||||
| 語速調節 | 滑桿 | 拖拽/+- | +/- | 0.5x-2x | 語音播放速度 |
|
||||
| 全螢幕模式 | 按鈕 | 點擊/F11 | F11 | 正常↔全螢幕 | 沉浸式對話模式 |
|
||||
| 多標籤切換 | 標籤頁 | 點擊/Ctrl+Tab | Ctrl+Tab | - | 切換不同對話 |
|
||||
|
||||
#### Web版使用者操作流程
|
||||
1. **對話準備**: 頁面載入 → 檢視角色背景 → 閱讀任務目標 → 預習關鍵詞
|
||||
2. **對話進行**: AI開場 → 用戶文字/語音回覆 → AI即時評分 → 繼續對話輪迴
|
||||
3. **輔助使用**: 卡住時查看提示 → 使用道具 → 查看任務進度 → 調整學習策略
|
||||
4. **多工學習**: 開啟新標籤 → 同時練習多個場景 → 標籤間切換比較
|
||||
|
||||
### Page_Dialogue_History_Dashboard_W - 對話歷史統計儀表板 (Web專用)
|
||||
|
||||
#### 功能說明
|
||||
- **頁面目的**: 提供詳細的對話練習歷史分析和進步軌跡
|
||||
- **進入條件**: 從對話分析頁面點擊"詳細統計"或從主選單進入
|
||||
- **退出條件**: 返回學習模組或關閉頁面
|
||||
|
||||
#### Web專有功能
|
||||
- **時間序列分析**: 對話表現的長期趨勢分析
|
||||
- **場景表現對比**: 不同情境場景的掌握度比較
|
||||
- **語言能力雷達圖**: 聽說讀寫能力的可視化分析
|
||||
- **學習建議生成**: AI基於歷史數據的個性化建議
|
||||
|
||||
#### 頁面欄位細節
|
||||
|
||||
| 欄位名稱 | 資料類型 | 必填 | 預設值 | 驗證規則 | 顯示條件 |
|
||||
|---------|---------|------|--------|----------|----------|
|
||||
| 統計時間範圍 | DateRange | 是 | 最近30天 | 有效日期範圍 | 頁面頂部篩選器 |
|
||||
| 對話總次數 | Number | 是 | 0 | >=0 | 卡片式統計顯示 |
|
||||
| 平均對話時長 | Number | 是 | 0 | >=0分鐘 | 統計卡片 |
|
||||
| 整體準確率 | Number | 是 | 0 | 0-100% | 統計卡片 |
|
||||
| 場景完成度分布 | Object | 是 | {} | 場景統計物件 | 圓餅圖顯示 |
|
||||
| 對話品質趨勢 | Array | 是 | [] | 時間序列數據 | 折線圖顯示 |
|
||||
| 語言能力評估 | Object | 是 | {} | 能力評分物件 | 雷達圖顯示 |
|
||||
| 常見錯誤類型 | Array | 是 | [] | 錯誤統計陣列 | 長條圖顯示 |
|
||||
| 學習建議列表 | Array | 是 | [] | 建議陣列 | 列表形式顯示 |
|
||||
|
||||
#### Web版互動元素
|
||||
|
||||
| 元素名稱 | 元素類型 | 操作方式 | 快捷鍵 | 狀態變化 | 備註 |
|
||||
|---------|---------|----------|--------|----------|------|
|
||||
| 時間範圍篩選 | 日期選擇器 | 點擊/T鍵 | T | 收合→展開 | 自訂時間範圍 |
|
||||
| 圖表類型切換 | 標籤組 | 點擊/數字鍵1-6 | 1-6 | - | 不同視覺化圖表 |
|
||||
| 數據鑽取 | 圖表點擊 | 點擊圖表元素 | - | 概覽→詳細 | 深入特定數據 |
|
||||
| 匯出統計報告 | 按鈕 | 點擊/Ctrl+E | Ctrl+E | 正常→處理中 | PDF/Excel匯出 |
|
||||
| 列印報告 | 按鈕 | 點擊/Ctrl+P | Ctrl+P | - | 列印優化版面 |
|
||||
| 全螢幕分析 | 按鈕 | 點擊/F11 | F11 | 正常↔全螢幕 | 專注分析模式 |
|
||||
| 圖表縮放 | 滑鼠滾輪 | 滾輪/+- | +/- | - | 放大細節檢視 |
|
||||
| 數據篩選 | 下拉選單 | 點擊/F鍵 | F | 全部→篩選 | 多維度篩選 |
|
||||
|
||||
### Page_Multi_Dialogue_Manager_W - 多對話管理頁面 (Web專用)
|
||||
|
||||
#### 功能說明
|
||||
- **頁面目的**: 在單一頁面中管理多個對話練習會話
|
||||
- **進入條件**: 點擊"開啟新對話"或使用快捷鍵Ctrl+T
|
||||
- **退出條件**: 關閉所有對話標籤或切換到單對話模式
|
||||
|
||||
#### Web專有優勢
|
||||
- **標籤式管理**: 類似瀏覽器標籤的對話會話管理
|
||||
- **拖拽重新排列**: 可拖拽調整對話標籤順序
|
||||
- **快速切換**: 鍵盤快捷鍵快速在對話間切換
|
||||
- **會話同步**: 所有對話進度即時同步到雲端
|
||||
|
||||
## 🌐 Web端技術特點
|
||||
|
||||
### 響應式設計適配
|
||||
- **超寬螢幕**: 2560px以上支援三欄佈局
|
||||
- **標準桌面**: 1920px最佳化雙欄佈局
|
||||
- **筆記本**: 1366px以上單欄加側邊欄
|
||||
- **平板橫向**: 1024px以上簡化佈局
|
||||
|
||||
### Web API整合
|
||||
- **Web Speech API**: 語音識別和語音合成
|
||||
- **Clipboard API**: 複製對話內容和答案
|
||||
- **Fullscreen API**: 沉浸式學習模式
|
||||
- **Notification API**: 桌面通知和提醒
|
||||
- **IndexedDB**: 離線對話內容快取
|
||||
|
||||
### 效能最佳化
|
||||
- **虛擬捲動**: 長對話歷史的效能最佳化
|
||||
- **懶載入**: 圖片和音頻按需載入
|
||||
- **Service Worker**: 離線對話功能支援
|
||||
- **Web Workers**: 背景AI分析處理
|
||||
|
||||
## ⌨️ Web版快捷鍵系統
|
||||
|
||||
### 對話操作快捷鍵
|
||||
- `Enter` - 送出回覆
|
||||
- `Shift + Enter` - 換行 (多行輸入)
|
||||
- `Ctrl + Enter` - 強制送出
|
||||
- `V` - 開始/停止語音輸入
|
||||
- `H` - 請求提示
|
||||
- `Space` - 播放/暫停AI語音
|
||||
- `Esc` - 取消當前操作
|
||||
|
||||
### 頁面導航快捷鍵
|
||||
- `Ctrl + T` - 開啟新對話標籤
|
||||
- `Ctrl + W` - 關閉當前對話標籤
|
||||
- `Ctrl + Tab` - 切換到下一個對話
|
||||
- `Ctrl + Shift + Tab` - 切換到上一個對話
|
||||
- `F11` - 全螢幕模式
|
||||
- `Ctrl + D` - 收藏當前對話場景
|
||||
|
||||
### 學習輔助快捷鍵
|
||||
- `I` - 顯示/隱藏角色資訊
|
||||
- `K` - 顯示/隱藏關鍵詞面板
|
||||
- `T` - 顯示/隱藏任務進度
|
||||
- `+/-` - 調整語音播放速度
|
||||
- `Ctrl + H` - 開啟對話歷史
|
||||
- `F1` - 開啟快捷鍵說明
|
||||
|
||||
## 📊 Web版業務邏輯差異
|
||||
|
||||
### 對話會話管理
|
||||
- **多會話支援**: 可同時進行最多5個對話練習
|
||||
- **會話持久化**: 瀏覽器關閉前自動保存所有對話狀態
|
||||
- **跨標籤同步**: 不同瀏覽器標籤的對話進度即時同步
|
||||
- **會話恢復**: 意外關閉後可完整恢復對話狀態
|
||||
|
||||
### 輸入方式增強
|
||||
- **混合輸入**: 文字和語音輸入可無縫切換
|
||||
- **多語言支援**: 支援各種輸入法和語言切換
|
||||
- **自動完成**: 常用回覆的智能建議
|
||||
- **語法檢查**: 實時語法和拼寫檢查
|
||||
|
||||
### 分析功能強化
|
||||
- **實時分析**: 對話進行中即時顯示表現評分
|
||||
- **深度統計**: 比Mobile版更詳細的學習分析
|
||||
- **長期追蹤**: 支援長達一年的學習軌跡分析
|
||||
- **對比分析**: 可對比不同時期的學習表現
|
||||
|
||||
## 🧪 Web版測試要點
|
||||
|
||||
### 瀏覽器相容性
|
||||
- [ ] Chrome 90+ Web Speech API正常運作
|
||||
- [ ] Firefox 85+ 語音輸入功能正常
|
||||
- [ ] Safari 14+ 多媒體播放流暢
|
||||
- [ ] Edge 90+ 全功能正常運作
|
||||
|
||||
### 多標籤功能測試
|
||||
- [ ] 可同時開啟5個對話會話
|
||||
- [ ] 標籤間切換無延遲
|
||||
- [ ] 每個標籤的對話狀態獨立
|
||||
- [ ] 關閉標籤不影響其他會話
|
||||
|
||||
### 效能壓力測試
|
||||
- [ ] 長對話歷史(500+輪)載入順暢
|
||||
- [ ] 同時播放多個語音檔案
|
||||
- [ ] 大量圖表數據渲染流暢
|
||||
- [ ] 長時間使用無記憶體洩漏
|
||||
|
||||
### 輸入法相容性
|
||||
- [ ] 中文輸入法正常運作
|
||||
- [ ] 日文輸入法正常運作
|
||||
- [ ] 韓文輸入法正常運作
|
||||
- [ ] 各種語音輸入準確識別
|
||||
|
||||
## 📝 Web端開發注意事項
|
||||
|
||||
### 前端架構
|
||||
- 使用React/Vue等現代框架
|
||||
- 狀態管理使用Redux/Vuex
|
||||
- 圖表使用D3.js或Chart.js
|
||||
- 語音處理使用Web Audio API
|
||||
|
||||
### 使用者體驗
|
||||
- 首屏快速載入(<2秒)
|
||||
- 對話輸入無延遲響應
|
||||
- 語音識別準確度>90%
|
||||
- 支援各種鍵盤快捷鍵
|
||||
|
||||
### 資料同步
|
||||
- 即時保存對話進度
|
||||
- 離線狀態下的資料快取
|
||||
- 跨設備同步學習記錄
|
||||
- 資料備份和恢復機制
|
||||
|
||||
---
|
||||
|
||||
**文檔狀態**: 🟢 已完成
|
||||
**最後更新**: 2025-09-09
|
||||
**版本**: v1.0
|
||||
**相關文檔**:
|
||||
- `../mobile/01_情境對話功能規格.md` - 對應的Mobile版規格
|
||||
- `../common/業務規則.md` - 共同業務邏輯
|
||||
- `../common/數據模型.md` - 數據結構定義
|
||||
- `../common/API規格.md` - API接口規格
|
||||
|
|
@ -1,304 +0,0 @@
|
|||
# 用戶認證功能規格文檔 (Web版)
|
||||
|
||||
## 📋 功能概述
|
||||
|
||||
**功能名稱**: 用戶認證系統 (Web端)
|
||||
**建立日期**: 2025-09-09
|
||||
**最後更新**: 2025-09-09
|
||||
**負責團隊**: 前端Web/設計/開發
|
||||
**對應Mobile規格**: `../mobile/05_用戶認證功能規格.md`
|
||||
|
||||
### 主要功能
|
||||
- 多元化註冊登入,支援Email、第三方OAuth、SSO等方式
|
||||
- 安全密碼管理,包含強度檢測、加密存儲、定期更新提醒
|
||||
- 多帳戶整合,支援多個第三方帳戶綁定和統一管理
|
||||
- 會話管理,靈活的登入狀態控制和安全登出
|
||||
- 帳戶安全保護,二次認證、異常登入檢測、帳戶鎖定機制
|
||||
- 個人資料管理,完整的用戶資訊編輯和隱私控制
|
||||
|
||||
### Web端特色功能
|
||||
- **SSO企業登入**: 支援企業級單一登入(SAML/OIDC)
|
||||
- **多設備管理**: 查看和管理所有登入設備
|
||||
- **記住登入狀態**: 可選擇記住登入30天/永久
|
||||
- **密碼管理器整合**: 與瀏覽器密碼管理器無縫整合
|
||||
- **安全金鑰支援**: WebAuthn/FIDO2安全金鑰登入
|
||||
- **帳戶資料匯出**: GDPR合規的個人資料匯出功能
|
||||
- **進階隱私設定**: 詳細的隱私控制和資料共享設定
|
||||
|
||||
### 適用場景
|
||||
- 企業和教育機構的統一帳戶管理
|
||||
- 需要高安全性的商務用戶認證
|
||||
- 多設備跨平台的帳戶同步需求
|
||||
- 家庭用戶的多成員帳戶管理
|
||||
|
||||
### 與其他功能的關聯
|
||||
- **學習系統**: 認證狀態決定學習內容和功能權限
|
||||
- **道具商店**: 付費功能需要安全的帳戶認證
|
||||
- **社交功能**: 帳戶綁定支援社交分享和好友系統
|
||||
- **數據分析**: 用戶認證數據用於個性化學習推薦
|
||||
- **客服系統**: 帳戶問題的客服支援和身份驗證
|
||||
|
||||
## 💻 涉及的Web頁面
|
||||
|
||||
### 主要頁面
|
||||
1. **Page_Login_Main_W** - 登入主頁面 (Web版)
|
||||
2. **Page_Register_Main_W** - 註冊主頁面 (Web版)
|
||||
3. **Page_Password_Reset_W** - 密碼重設頁面 (Web版)
|
||||
4. **Page_Profile_Main_W** - 個人資料頁面 (Web版)
|
||||
5. **Page_Account_Settings_W** - 帳戶設定頁面 (Web版)
|
||||
6. **Page_Security_Settings_W** - 安全設定頁面 (Web版)
|
||||
|
||||
### Web專用頁面
|
||||
1. **Page_Device_Management_W** - 設備管理頁面 (Web專用)
|
||||
2. **Page_Privacy_Settings_W** - 隱私設定頁面 (Web專用)
|
||||
3. **Page_Data_Export_W** - 資料匯出頁面 (Web專用)
|
||||
4. **Page_Account_Linking_W** - 帳戶綁定管理 (Web專用)
|
||||
5. **Page_Enterprise_SSO_W** - 企業SSO設定 (Web專用)
|
||||
|
||||
### 輔助頁面
|
||||
1. **Page_Email_Verification_W** - 電子郵件驗證頁面
|
||||
2. **Page_Two_Factor_Setup_W** - 二次認證設定頁面
|
||||
3. **Page_Account_Recovery_W** - 帳戶恢復頁面
|
||||
4. **Modal_Security_Alert_W** - 安全警告模態視窗
|
||||
|
||||
## 🎯 詳細頁面規格
|
||||
|
||||
### Page_Login_Main_W - 登入主頁面 (Web版)
|
||||
|
||||
#### 功能說明
|
||||
- **頁面目的**: 在桌面環境提供安全便捷的用戶登入體驗
|
||||
- **進入條件**: 訪問需要認證的功能或直接輸入登入URL
|
||||
- **退出條件**: 成功登入後跳轉到目標頁面或主頁
|
||||
|
||||
#### Web版佈局特點
|
||||
- **居中登入卡片**: 響應式登入表單,支援多種螢幕尺寸
|
||||
- **第三方登入區域**: 並列顯示多個第三方登入選項
|
||||
- **安全提示區域**: 顯示安全建議和最近登入資訊
|
||||
- **企業登入入口**: 企業用戶的SSO登入入口
|
||||
- **背景視覺設計**: 品牌一致的背景圖片或動畫
|
||||
|
||||
#### 頁面欄位細節
|
||||
|
||||
| 欄位名稱 | 資料類型 | 必填 | 預設值 | 驗證規則 | 顯示條件 |
|
||||
|---------|---------|------|--------|----------|----------|
|
||||
| 登入帳號 | String | 是 | - | Email格式或用戶名 | 主要輸入框 |
|
||||
| 登入密碼 | String | 是 | - | 6-128字符 | 密碼輸入框 |
|
||||
| 記住登入 | Boolean | 否 | false | true/false | 記住登入選項 |
|
||||
| 記住時長 | String | 否 | "30days" | 時長枚舉 | 記住登入子選項 |
|
||||
| 驗證碼 | String | 否 | - | 驗證碼格式 | 安全策略觸發時 |
|
||||
| 登入方式 | String | 是 | "email" | 登入方式枚舉 | 登入方式切換 |
|
||||
| 企業網域 | String | 否 | - | 網域格式 | 企業登入模式 |
|
||||
| 上次登入時間 | Date | 否 | - | 日期時間 | 歡迎回來提示 |
|
||||
| 登入裝置資訊 | String | 否 | - | 裝置資訊 | 安全提示 |
|
||||
|
||||
#### Web版互動元素
|
||||
|
||||
| 元素名稱 | 元素類型 | 操作方式 | 快捷鍵 | 狀態變化 | 備註 |
|
||||
|---------|---------|----------|--------|----------|------|
|
||||
| 帳號輸入框 | 文本框 | 點擊/自動焦點 | Tab | 空白→輸入中 | 支援自動完成 |
|
||||
| 密碼輸入框 | 密碼框 | 點擊/Tab | Tab | 隱藏→顯示 | 顯示/隱藏切換 |
|
||||
| 登入按鈕 | 按鈕 | 點擊/Enter | Enter | 正常→登入中 | 主要操作按鈕 |
|
||||
| 忘記密碼連結 | 連結 | 點擊/F鍵 | F | - | 跳轉密碼重設 |
|
||||
| Google登入 | 按鈕 | 點擊/G鍵 | G | 正常→認證中 | 第三方OAuth |
|
||||
| Apple登入 | 按鈕 | 點擊/A鍵 | A | 正常→認證中 | 第三方OAuth |
|
||||
| 企業SSO | 按鈕 | 點擊/E鍵 | E | 正常→跳轉中 | 企業登入入口 |
|
||||
| 密碼顯示切換 | 按鈕 | 點擊/Ctrl+H | Ctrl+H | 隱藏↔顯示 | 密碼可視性控制 |
|
||||
| 驗證碼刷新 | 按鈕 | 點擊/R鍵 | R | - | 重新獲取驗證碼 |
|
||||
|
||||
#### Web版使用者操作流程
|
||||
1. **基本登入**: 輸入帳號密碼 → 選擇記住登入 → 點擊登入 → 驗證成功進入系統
|
||||
2. **第三方登入**: 選擇第三方平台 → 跳轉認證 → 授權確認 → 回到應用完成登入
|
||||
3. **企業登入**: 選擇企業登入 → 輸入企業網域 → 跳轉SSO → 企業認證 → 自動登入
|
||||
4. **安全驗證**: 觸發安全檢查 → 輸入驗證碼 → 通過二次認證 → 成功登入
|
||||
|
||||
### Page_Privacy_Settings_W - 隱私設定頁面 (Web專用)
|
||||
|
||||
#### 功能說明
|
||||
- **頁面目的**: 提供完整的隱私控制設定,符合GDPR等隱私法規要求
|
||||
- **進入條件**: 從帳戶設定進入或隱私政策連結進入
|
||||
- **退出條件**: 保存隱私設定或取消修改
|
||||
|
||||
#### Web專有隱私功能
|
||||
- **資料處理同意**: 詳細的資料處理用途說明和同意選項
|
||||
- **Cookie控制**: 細粒度的Cookie類別控制
|
||||
- **資料共享設定**: 控制資料與第三方的共享範圍
|
||||
- **行為追蹤控制**: 學習行為和使用數據的追蹤設定
|
||||
- **資料保留政策**: 個人資料的保留期限設定
|
||||
- **匿名化選項**: 資料匿名化處理的選擇
|
||||
|
||||
#### 頁面欄位細節
|
||||
|
||||
| 欄位名稱 | 資料類型 | 必填 | 預設值 | 驗證規則 | 顯示條件 |
|
||||
|---------|---------|------|--------|----------|----------|
|
||||
| 資料收集同意 | Object | 是 | {} | 同意設定物件 | 分類同意區域 |
|
||||
| Cookie偏好設定 | Object | 是 | {} | Cookie設定 | Cookie控制面板 |
|
||||
| 行銷通訊同意 | Boolean | 是 | false | true/false | 通訊偏好設定 |
|
||||
| 第三方資料共享 | Object | 是 | {} | 共享設定物件 | 資料共享控制 |
|
||||
| 個人化設定 | Boolean | 是 | true | true/false | 個人化同意 |
|
||||
| 分析資料收集 | Boolean | 是 | false | true/false | 分析同意設定 |
|
||||
| 資料匯出格式 | String | 否 | "json" | 格式枚舉 | 資料匯出選項 |
|
||||
| 帳戶刪除原因 | String | 否 | - | 1-500字 | 刪除帳戶時 |
|
||||
|
||||
### Page_Device_Management_W - 設備管理頁面 (Web專用)
|
||||
|
||||
#### 功能說明
|
||||
- **頁面目的**: 管理所有已登入的設備,提供安全的設備控制功能
|
||||
- **進入條件**: 從安全設定進入或安全警告時引導進入
|
||||
- **退出條件**: 完成設備管理或返回安全設定
|
||||
|
||||
#### Web專有設備管理
|
||||
- **活躍設備列表**: 顯示所有當前登入的設備
|
||||
- **設備詳細資訊**: 設備類型、瀏覽器、IP位置、登入時間
|
||||
- **遠程登出**: 可遠程登出指定設備或全部設備
|
||||
- **可信設備**: 標記可信設備,減少安全驗證
|
||||
- **登入通知**: 新設備登入的電子郵件通知設定
|
||||
|
||||
#### 頁面欄位細節
|
||||
|
||||
| 欄位名稱 | 資料類型 | 必填 | 預設值 | 驗證規則 | 顯示條件 |
|
||||
|---------|---------|------|--------|----------|----------|
|
||||
| 設備列表 | Array | 是 | [] | 設備陣列 | 主要列表區域 |
|
||||
| 當前設備標識 | String | 是 | - | 設備ID | 當前設備標記 |
|
||||
| 設備類型 | String | 是 | - | 設備類型枚舉 | 設備圖示 |
|
||||
| 瀏覽器資訊 | String | 是 | - | 瀏覽器字串 | 技術資訊 |
|
||||
| IP位置 | String | 是 | - | IP地址 | 地理位置 |
|
||||
| 最後活躍時間 | Date | 是 | - | 日期時間 | 活動時間 |
|
||||
| 可信狀態 | Boolean | 是 | false | true/false | 信任標記 |
|
||||
| 登入通知設定 | Boolean | 是 | true | true/false | 通知偏好 |
|
||||
|
||||
## 🌐 Web端技術特點
|
||||
|
||||
### 企業級認證整合
|
||||
- **SAML 2.0**: 支援SAML單一登入協議
|
||||
- **OpenID Connect**: OIDC標準認證流程
|
||||
- **LDAP整合**: 企業LDAP目錄服務整合
|
||||
- **Active Directory**: 微軟AD域控制器整合
|
||||
|
||||
### 現代Web認證標準
|
||||
- **WebAuthn**: 無密碼登入和硬體安全金鑰
|
||||
- **FIDO2**: 強認證標準支援
|
||||
- **PassKeys**: 蘋果/Google PassKeys整合
|
||||
- **Biometric**: 瀏覽器生物識別API
|
||||
|
||||
### 安全性增強
|
||||
- **CSP嚴格模式**: 內容安全政策防止XSS
|
||||
- **SameSite Cookie**: 防止CSRF攻擊
|
||||
- **HSTS**: 強制HTTPS傳輸安全
|
||||
- **Rate Limiting**: API速率限制防止暴力破解
|
||||
|
||||
### 隱私合規支援
|
||||
- **GDPR合規**: 歐盟一般資料保護規範
|
||||
- **CCPA合規**: 加州消費者隱私法案
|
||||
- **Cookie Law**: 歐盟Cookie指令合規
|
||||
- **Data Portability**: 資料可攜權實現
|
||||
|
||||
## ⌨️ Web版快捷鍵系統
|
||||
|
||||
### 認證頁面快捷鍵
|
||||
- `Tab` - 在表單欄位間切換
|
||||
- `Enter` - 提交當前表單
|
||||
- `Esc` - 取消當前操作
|
||||
- `F` - 快速前往忘記密碼
|
||||
- `G` - Google登入
|
||||
- `A` - Apple登入
|
||||
- `E` - 企業登入
|
||||
|
||||
### 設定頁面快捷鍵
|
||||
- `Ctrl + S` - 保存設定
|
||||
- `Ctrl + R` - 重置為預設值
|
||||
- `Ctrl + E` - 匯出個人資料
|
||||
- `Ctrl + D` - 下載資料副本
|
||||
- `Delete` - 刪除選中項目
|
||||
|
||||
### 安全操作快捷鍵
|
||||
- `Ctrl + L` - 登出當前設備
|
||||
- `Ctrl + Shift + L` - 登出所有設備
|
||||
- `Ctrl + T` - 切換可信設備狀態
|
||||
- `F5` - 重新整理設備列表
|
||||
- `Ctrl + N` - 開啟新的安全金鑰設定
|
||||
|
||||
## 📊 Web版業務邏輯差異
|
||||
|
||||
### 會話管理策略
|
||||
- **長效會話**: 支援30天/永久記住登入
|
||||
- **多標籤同步**: 跨瀏覽器標籤的登入狀態同步
|
||||
- **自動續期**: 活躍使用時自動延長會話期限
|
||||
- **閒置檢測**: 檢測用戶閒置並提示安全登出
|
||||
|
||||
### 密碼安全增強
|
||||
- **密碼強度指示**: 即時密碼強度評估和建議
|
||||
- **密碼歷史**: 防止重複使用近期密碼
|
||||
- **自動生成**: 集成密碼生成器建議強密碼
|
||||
- **洩漏檢測**: 檢測密碼是否出現在已知洩漏資料庫
|
||||
|
||||
### 隱私控制細化
|
||||
- **分級同意**: 不同類別資料的分別同意機制
|
||||
- **同意撤回**: 隨時撤回資料處理同意
|
||||
- **影響說明**: 清楚說明撤回同意對功能的影響
|
||||
- **資料最小化**: 僅收集必要的最少資料
|
||||
|
||||
## 🧪 Web版測試要點
|
||||
|
||||
### 認證流程測試
|
||||
- [ ] 基本帳密登入流程正常
|
||||
- [ ] 第三方OAuth登入正常
|
||||
- [ ] 密碼重設流程完整
|
||||
- [ ] 帳戶註冊驗證正常
|
||||
- [ ] 二次認證設定和使用正常
|
||||
|
||||
### 安全功能測試
|
||||
- [ ] 異常登入檢測和通知
|
||||
- [ ] 帳戶鎖定機制正確觸發
|
||||
- [ ] 設備管理功能完整
|
||||
- [ ] 遠程登出功能正常
|
||||
- [ ] 安全金鑰登入正常
|
||||
|
||||
### 隱私合規測試
|
||||
- [ ] GDPR資料匯出功能正常
|
||||
- [ ] Cookie同意機制正確
|
||||
- [ ] 資料處理同意記錄完整
|
||||
- [ ] 帳戶刪除流程合規
|
||||
- [ ] 隱私政策同意機制正常
|
||||
|
||||
### 跨瀏覽器測試
|
||||
- [ ] Chrome認證功能完整
|
||||
- [ ] Firefox第三方登入正常
|
||||
- [ ] Safari WebAuthn支援正常
|
||||
- [ ] Edge企業SSO功能正常
|
||||
|
||||
## 📝 Web端開發注意事項
|
||||
|
||||
### 安全實作要求
|
||||
- 所有認證相關請求強制HTTPS
|
||||
- 敏感資訊絕不在前端儲存
|
||||
- 實施嚴格的CSP政策
|
||||
- 使用安全的會話管理機制
|
||||
|
||||
### 隱私合規實作
|
||||
- 實施同意管理平台(CMP)
|
||||
- 提供完整的資料處理透明度
|
||||
- 實現用戶權利行使機制
|
||||
- 定期隱私影響評估
|
||||
|
||||
### 使用者體驗設計
|
||||
- 簡化認證流程,減少摩擦
|
||||
- 提供清楚的錯誤訊息和解決方案
|
||||
- 支援無障礙設備和輔助技術
|
||||
- 響應式設計適應各種螢幕尺寸
|
||||
|
||||
### 效能最佳化
|
||||
- 認證頁面快速載入(<1秒)
|
||||
- 第三方認證回調處理最佳化
|
||||
- 設備列表分頁載入避免效能問題
|
||||
- 使用適當的快取策略
|
||||
|
||||
---
|
||||
|
||||
**文檔狀態**: 🟢 已完成
|
||||
**最後更新**: 2025-09-09
|
||||
**版本**: v1.0
|
||||
**相關文檔**:
|
||||
- `../mobile/05_用戶認證功能規格.md` - 對應的Mobile版規格
|
||||
- `../common/業務規則.md` - 共同業務邏輯
|
||||
- `../common/數據模型.md` - 數據結構定義
|
||||
- `../common/API規格.md` - API接口規格
|
||||
|
|
@ -1,257 +0,0 @@
|
|||
# 詞彙學習功能規格文檔 (Web版)
|
||||
|
||||
## 📋 功能概述
|
||||
|
||||
**功能名稱**: 詞彙學習訓練系統 (Web端)
|
||||
**建立日期**: 2025-09-09
|
||||
**最後更新**: 2025-09-09
|
||||
**負責團隊**: 前端Web/設計/開發
|
||||
**對應Mobile規格**: `../mobile/02_詞彙學習功能規格.md`
|
||||
|
||||
### 主要功能
|
||||
- 漸進式詞彙學習路徑:介紹→練習→測試→複習
|
||||
- 多維度練習模式:選擇題、圖片匹配、句子應用
|
||||
- 流暢度評估系統:反應時間與正確率綜合評判
|
||||
- 間隔複習機制:基於遺忘曲線的智能複習安排
|
||||
- 個人化學習調整:根據表現動態調整難度和內容
|
||||
|
||||
### Web端特色功能
|
||||
- **快捷鍵操作**: 支援鍵盤快捷鍵提升學習效率
|
||||
- **大螢幕優化**: 利用桌面螢幕空間展示更多學習內容
|
||||
- **多視窗支援**: 可同時開啟多個學習模組進行對比學習
|
||||
- **高級統計面板**: 詳細的學習數據可視化分析
|
||||
|
||||
### 適用場景
|
||||
- 辦公室或家中的深度學習時段
|
||||
- 需要大量文字輸入的詞彙練習
|
||||
- 詳細學習數據分析和複習規劃
|
||||
- 多螢幕環境下的沉浸式學習
|
||||
|
||||
### 與其他功能的關聯
|
||||
- **情境對話系統**: 為對話提供詞彙基礎,指定詞彙在對話中使用
|
||||
- **學習地圖系統**: 按階段解鎖詞彙學習內容
|
||||
- **複習系統**: 整合間隔複習演算法,安排詞彙複習
|
||||
- **成就系統**: 詞彙掌握里程碑和學習成就追蹤
|
||||
|
||||
## 💻 涉及的Web頁面
|
||||
|
||||
### 主要頁面
|
||||
1. **Page_Vocab_Introduction_W** - 詞彙介紹主頁面 (Web版)
|
||||
2. **Page_Vocab_Choice_Practice_W** - 詞彙選擇練習頁面 (Web版)
|
||||
3. **Page_Vocab_Fluency_Matching_W** - 圖片匹配練習頁面 (Web版)
|
||||
4. **Page_Vocab_Fluency_Reorganize_W** - 句子重組練習頁面 (Web版)
|
||||
5. **Page_Vocab_Review_Main_W** - 詞彙複習主頁面 (Web版)
|
||||
|
||||
### 結果反饋頁面
|
||||
1. **Page_Vocab_Choice_Results_W** - 選擇題結果分析 (Web版)
|
||||
2. **Page_Vocab_Fluency_Results_W** - 流暢度練習綜合結果 (Web版)
|
||||
3. **Page_Vocab_Analytics_Dashboard_W** - 詞彙學習分析儀表板 (Web專用)
|
||||
|
||||
## 🎯 詳細頁面規格
|
||||
|
||||
### Page_Vocab_Introduction_W - 詞彙介紹主頁面 (Web版)
|
||||
|
||||
#### 功能說明
|
||||
- **頁面目的**: 在桌面環境中為用戶介紹新詞彙,提供豐富的學習資源和互動體驗
|
||||
- **進入條件**: 從學習地圖選擇詞彙學習關卡,或透過瀏覽器書籤直接進入
|
||||
- **退出條件**: 完成詞彙介紹進入練習階段,或關閉瀏覽器標籤
|
||||
|
||||
#### Web版特有功能
|
||||
- **多列布局**: 左側詞彙資訊,右側相關詞彙和例句
|
||||
- **詞典整合**: 滑鼠懸停即時顯示釋義,右鍵查詢外部詞典
|
||||
- **筆記功能**: 內建筆記編輯器,支援Markdown格式
|
||||
- **書籤管理**: 瀏覽器書籤整合,快速收藏重要詞彙
|
||||
|
||||
#### 頁面欄位細節
|
||||
|
||||
| 欄位名稱 | 資料類型 | 必填 | 預設值 | 驗證規則 | 顯示條件 |
|
||||
|---------|---------|------|--------|----------|----------|
|
||||
| 目標詞彙文字 | String | 是 | - | 1-50字 | 始終顯示,大字體標題 |
|
||||
| 音標顯示 | String | 是 | - | IPA音標格式 | 詞彙下方,支援點擊複製 |
|
||||
| 中文定義 | String | 是 | - | 10-100字 | 主要定義區域 |
|
||||
| 英文定義 | String | 否 | - | 10-200字 | 進階模式顯示,可切換 |
|
||||
| 詞性標記 | String | 是 | - | n./v./adj.等 | 色彩編碼顯示 |
|
||||
| 例句1-5 | String | 是 | - | 10-100字 | 多例句並列顯示 |
|
||||
| 使用情境說明 | String | 是 | - | 20-200字 | 獨立區塊顯示 |
|
||||
| 相關詞彙推薦 | Array | 否 | [] | 詞彙陣列 | 右側面板顯示 |
|
||||
| 詞頻統計 | Number | 否 | - | 1-5星評級 | 使用頻率指示 |
|
||||
| 用戶筆記 | String | 否 | - | 不限長度 | 可摺疊筆記區域 |
|
||||
| 學習進度 | Number | 是 | 0 | 0-100% | 進度條顯示 |
|
||||
|
||||
#### Web版互動元素
|
||||
|
||||
| 元素名稱 | 元素類型 | 操作方式 | 快捷鍵 | 狀態變化 | 備註 |
|
||||
|---------|---------|----------|--------|----------|------|
|
||||
| 發音播放按鈕 | 按鈕 | 點擊/空白鍵 | Space | 正常→播放中 | 支援重複播放和語速調節 |
|
||||
| 慢速發音按鈕 | 按鈕 | 點擊/Shift+Space | Shift+Space | 正常→播放中 | 0.5x-1.5x語速調節 |
|
||||
| 例句發音按鈕 | 按鈕 | 點擊/數字鍵1-5 | 1-5 | 正常→播放中 | 每個例句對應數字鍵 |
|
||||
| 收藏按鈕 | 按鈕 | 點擊/Ctrl+D | Ctrl+D | 未收藏↔已收藏 | 整合瀏覽器書籤 |
|
||||
| 相關詞彙按鈕 | 按鈕 | 點擊/右鍵新標籤 | Ctrl+Click | - | 支援新標籤開啟 |
|
||||
| 筆記編輯器 | 文本區域 | 點擊/Ctrl+N | Ctrl+N | 收合→展開 | 支援Markdown語法 |
|
||||
| 詞典查詢按鈕 | 按鈕 | 右鍵選單 | F1 | - | 新視窗開啟外部詞典 |
|
||||
| 開始練習按鈕 | 按鈕 | 點擊/Enter | Enter | - | 主要行動按鈕 |
|
||||
| 跳過介紹按鈕 | 按鈕 | 點擊/Shift+Enter | Shift+Enter | - | 快速通道 |
|
||||
|
||||
#### Web版使用者操作流程
|
||||
1. **快速瀏覽**: 頁面載入 → 自動播放發音 → 快速瀏覽定義和例句
|
||||
2. **深度學習**: 展開筆記編輯器 → 記錄重點 → 查詢相關詞彙 → 使用快捷鍵快速操作
|
||||
3. **多標籤學習**: 右鍵開啟相關詞彙 → 多標籤對比學習 → 統一管理學習進度
|
||||
4. **鍵盤操作**: 全程使用快捷鍵 → 提升學習效率 → 無需使用滑鼠
|
||||
|
||||
### Page_Vocab_Analytics_Dashboard_W - 詞彙學習分析儀表板 (Web專用)
|
||||
|
||||
#### 功能說明
|
||||
- **頁面目的**: 提供詳細的詞彙學習數據分析和可視化圖表
|
||||
- **進入條件**: 從詞彙學習結果頁面點擊"詳細分析",或從主選單進入
|
||||
- **退出條件**: 返回學習模組或關閉頁面
|
||||
|
||||
#### Web版專有功能
|
||||
- **多維度數據視覺化**: 雷達圖、趨勢圖、熱力圖等豐富圖表
|
||||
- **自訂報告**: 用戶可選擇時間範圍和分析維度
|
||||
- **數據匯出**: 支援CSV、PDF格式匯出
|
||||
- **印刷友善格式**: 優化列印版面配置
|
||||
|
||||
#### 頁面欄位細節
|
||||
|
||||
| 欄位名稱 | 資料類型 | 必填 | 預設值 | 驗證規則 | 顯示條件 |
|
||||
|---------|---------|------|--------|----------|----------|
|
||||
| 時間範圍選擇器 | DateRange | 是 | 最近7天 | 有效日期範圍 | 頁面頂部,始終可見 |
|
||||
| 整體學習統計 | Object | 是 | - | 統計物件 | 卡片式布局顯示 |
|
||||
| 詞彙掌握度分布圖 | Chart | 是 | - | 圖表數據 | 圓餅圖顯示 |
|
||||
| 學習進度趨勢圖 | Chart | 是 | - | 時間序列數據 | 折線圖顯示 |
|
||||
| 錯誤分析熱力圖 | Chart | 是 | - | 矩陣數據 | 熱力圖顯示 |
|
||||
| 詞彙分類統計 | Table | 是 | - | 表格數據 | 可排序表格 |
|
||||
| 學習建議清單 | Array | 是 | - | 建議陣列 | 列表形式顯示 |
|
||||
| 薄弱點識別 | Array | 是 | - | 詞彙陣列 | 標籤雲顯示 |
|
||||
|
||||
#### Web版互動元素
|
||||
|
||||
| 元素名稱 | 元素類型 | 操作方式 | 快捷鍵 | 狀態變化 | 備註 |
|
||||
|---------|---------|----------|--------|----------|------|
|
||||
| 時間範圍篩選器 | 日期選擇器 | 點擊/Tab導航 | T | 收合→展開 | 支援快速預設範圍 |
|
||||
| 圖表縮放控制 | 按鈕組 | 滑鼠滾輪/+- | +/- | - | 支援圖表放大縮小 |
|
||||
| 數據篩選器 | 下拉選單 | 點擊/方向鍵 | F | - | 多選篩選條件 |
|
||||
| 匯出按鈕 | 按鈕 | 點擊/Ctrl+E | Ctrl+E | 正常→處理中 | 背景處理匯出 |
|
||||
| 列印按鈕 | 按鈕 | 點擊/Ctrl+P | Ctrl+P | - | 優化列印格式 |
|
||||
| 全螢幕按鈕 | 按鈕 | 點擊/F11 | F11 | 正常↔全螢幕 | 沉浸式分析模式 |
|
||||
| 數據表格排序 | 表格標題 | 點擊/方向鍵 | ↑/↓ | 升序↔降序 | 支援多列排序 |
|
||||
| 圖表類型切換 | 選項卡 | 點擊/數字鍵 | 1-5 | - | 快速切換圖表類型 |
|
||||
|
||||
## 🌐 Web端技術特點
|
||||
|
||||
### 響應式設計
|
||||
- **桌面優先**: 1200px以上寬度的最佳化設計
|
||||
- **平板適應**: 768px-1199px的平板橫向模式支援
|
||||
- **快捷鍵系統**: 完整的鍵盤操作支援
|
||||
- **無障礙設計**: 符合WCAG 2.1 AA標準
|
||||
|
||||
### 效能最佳化
|
||||
- **懶載入**: 圖片和音頻按需載入
|
||||
- **快取策略**: 離線學習內容快取
|
||||
- **預載入**: 預先載入下一個詞彙內容
|
||||
- **CDN加速**: 音頻和圖片資源CDN分發
|
||||
|
||||
### 瀏覽器整合
|
||||
- **書籤同步**: 與瀏覽器書籤系統整合
|
||||
- **歷史記錄**: 學習歷程納入瀏覽器歷史
|
||||
- **標籤管理**: 支援多標籤同時學習
|
||||
- **快捷鍵**: 瀏覽器原生快捷鍵支援
|
||||
|
||||
## ⌨️ Web版快捷鍵一覽
|
||||
|
||||
### 通用快捷鍵
|
||||
- `Space` - 播放/暫停詞彙發音
|
||||
- `Shift + Space` - 播放慢速發音
|
||||
- `1-5` - 播放對應例句發音
|
||||
- `Enter` - 確認/下一步
|
||||
- `Esc` - 取消/返回
|
||||
- `Tab` - 焦點移動
|
||||
- `Ctrl + D` - 收藏詞彙
|
||||
- `Ctrl + N` - 開啟/關閉筆記
|
||||
- `F1` - 開啟詞典查詢
|
||||
|
||||
### 學習過程快捷鍵
|
||||
- `A/B/C/D` - 選擇對應選項
|
||||
- `Ctrl + Enter` - 提交答案
|
||||
- `Shift + Enter` - 跳過題目
|
||||
- `Ctrl + R` - 重新開始
|
||||
- `Ctrl + H` - 顯示提示
|
||||
|
||||
### 分析頁面快捷鍵
|
||||
- `T` - 開啟時間範圍選擇器
|
||||
- `F` - 開啟篩選器
|
||||
- `Ctrl + E` - 匯出數據
|
||||
- `Ctrl + P` - 列印報告
|
||||
- `F11` - 全螢幕模式
|
||||
- `+/-` - 縮放圖表
|
||||
|
||||
## 📊 Web版業務邏輯差異
|
||||
|
||||
### 學習會話管理
|
||||
- **多標籤支援**: 可同時進行多個學習會話
|
||||
- **會話暫存**: 瀏覽器關閉前自動保存學習進度
|
||||
- **跨裝置同步**: 透過帳戶同步學習狀態
|
||||
- **離線模式**: 支援離線學習,上線後同步
|
||||
|
||||
### 數據分析增強
|
||||
- **實時圖表**: 學習過程中即時更新統計圖表
|
||||
- **歷史對比**: 可對比不同時間段的學習表現
|
||||
- **詳細報告**: 比Mobile版更詳盡的分析報告
|
||||
- **數據匯出**: 支援學習數據的多格式匯出
|
||||
|
||||
## 🧪 Web版測試要點
|
||||
|
||||
### 瀏覽器相容性測試
|
||||
- [ ] Chrome 90+ 功能完整性
|
||||
- [ ] Firefox 85+ 功能完整性
|
||||
- [ ] Safari 14+ 功能完整性
|
||||
- [ ] Edge 90+ 功能完整性
|
||||
|
||||
### 響應式測試
|
||||
- [ ] 1920x1080 桌面解析度最佳化
|
||||
- [ ] 1366x768 筆電解析度適配
|
||||
- [ ] 1024x768 平板橫向模式
|
||||
- [ ] 縮放至50%-200%正常顯示
|
||||
|
||||
### 快捷鍵測試
|
||||
- [ ] 所有定義快捷鍵正常工作
|
||||
- [ ] 快捷鍵與瀏覽器原生功能不衝突
|
||||
- [ ] 焦點管理和鍵盤導航順序正確
|
||||
- [ ] 無障礙輔助工具相容性
|
||||
|
||||
### 效能測試
|
||||
- [ ] 頁面載入時間 < 3秒
|
||||
- [ ] 音頻播放延遲 < 200ms
|
||||
- [ ] 圖表渲染流暢度 >= 30fps
|
||||
- [ ] 記憶體使用量合理範圍
|
||||
|
||||
## 📝 Web端開發注意事項
|
||||
|
||||
### 前端開發
|
||||
- 使用現代JavaScript框架 (React/Vue/Angular)
|
||||
- 圖表庫選用 D3.js 或 Chart.js
|
||||
- 音頻播放使用 Web Audio API
|
||||
- 響應式設計使用 CSS Grid 和 Flexbox
|
||||
|
||||
### 用戶體驗
|
||||
- 首屏載入優化,關鍵內容優先載入
|
||||
- 快捷鍵提示和幫助系統
|
||||
- 錯誤處理和離線狀態提示
|
||||
- 學習進度的視覺化反饋
|
||||
|
||||
### 整合注意事項
|
||||
- PWA支援,可安裝為桌面應用
|
||||
- 通知API整合,支援桌面通知
|
||||
- 資料同步策略,離線優先設計
|
||||
- SEO優化,學習內容可被搜尋引擎索引
|
||||
|
||||
---
|
||||
|
||||
**文檔狀態**: 🟢 已完成
|
||||
**最後更新**: 2025-09-09
|
||||
**版本**: v1.0
|
||||
**相關文檔**:
|
||||
- `../mobile/02_詞彙學習功能規格.md` - 對應的Mobile版規格
|
||||
- `../common/業務規則.md` - 共同業務邏輯
|
||||
- `../common/數據模型.md` - 數據結構定義
|
||||
- `../common/API規格.md` - API接口規格
|
||||
|
|
@ -1,298 +0,0 @@
|
|||
# 道具商店功能規格文檔 (Web版)
|
||||
|
||||
## 📋 功能概述
|
||||
|
||||
**功能名稱**: 道具商店系統 (Web端)
|
||||
**建立日期**: 2025-09-09
|
||||
**最後更新**: 2025-09-09
|
||||
**負責團隊**: 前端Web/設計/開發
|
||||
**對應Mobile規格**: `../mobile/04_道具商店功能規格.md`
|
||||
|
||||
### 主要功能
|
||||
- 多層次道具系統,涵蓋命條、提示、加速、裝飾等類型
|
||||
- 靈活的定價策略,包含鑽石、學習幣、真實貨幣支付
|
||||
- 組合優惠機制,促進多道具購買和長期訂閱
|
||||
- 個人化推薦,基於學習習慣推薦合適道具
|
||||
- 庫存管理,道具使用記錄和剩餘數量追蹤
|
||||
- 購買歷史,完整的交易記錄和退款處理
|
||||
|
||||
### Web端特色功能
|
||||
- **網格式商店佈局**: 利用大螢幕展示更多商品
|
||||
- **進階篩選系統**: 多維度商品篩選和排序
|
||||
- **批量購買操作**: 可同時購買多種道具組合
|
||||
- **購物車功能**: 類似電商的購物車體驗
|
||||
- **價格對比工具**: 不同套餐的價格效益分析
|
||||
- **訂閱管理中心**: 完整的訂閱服務管理
|
||||
- **發票和收據**: 完整的購買憑證系統
|
||||
|
||||
### 適用場景
|
||||
- 桌面環境的深度購物和比價體驗
|
||||
- 企業用戶的批量採購和付費管理
|
||||
- 家長為孩子管理學習道具和付費控制
|
||||
- 長期學習用戶的訂閱服務管理
|
||||
|
||||
### 與其他功能的關聯
|
||||
- **學習系統**: 道具使用提升學習效率和體驗
|
||||
- **命條系統**: 命條相關道具的購買和補充
|
||||
- **成就系統**: 購買特殊道具解鎖成就
|
||||
- **用戶系統**: 付費狀態影響功能權限
|
||||
- **分析系統**: 購買行為數據用於商品推薦
|
||||
|
||||
## 💻 涉及的Web頁面
|
||||
|
||||
### 主要頁面
|
||||
1. **Page_Shop_Main_W** - 道具商店主頁面 (Web版)
|
||||
2. **Page_Shop_Category_W** - 分類商品頁面 (Web版)
|
||||
3. **Page_Shop_Item_Details_W** - 商品詳情頁面 (Web版)
|
||||
4. **Page_Shopping_Cart_W** - 購物車頁面 (Web版)
|
||||
5. **Page_Checkout_W** - 結帳頁面 (Web版)
|
||||
6. **Page_Purchase_History_W** - 購買歷史頁面 (Web版)
|
||||
7. **Page_Inventory_Main_W** - 道具庫存頁面 (Web版)
|
||||
|
||||
### Web專用頁面
|
||||
1. **Page_Subscription_Management_W** - 訂閱管理中心 (Web專用)
|
||||
2. **Page_Price_Comparison_W** - 價格對比工具 (Web專用)
|
||||
3. **Page_Bulk_Purchase_W** - 批量採購頁面 (Web專用)
|
||||
4. **Page_Payment_Methods_W** - 支付方式管理 (Web專用)
|
||||
|
||||
### 輔助畫面
|
||||
1. **Modal_Payment_Confirm_W** - 付款確認模態視窗
|
||||
2. **Modal_Item_Preview_W** - 道具預覽模態視窗
|
||||
3. **Modal_Refund_Request_W** - 退款申請模態視窗
|
||||
4. **Panel_Quick_Buy_W** - 快速購買面板
|
||||
|
||||
## 🎯 詳細頁面規格
|
||||
|
||||
### Page_Shop_Main_W - 道具商店主頁面 (Web版)
|
||||
|
||||
#### 功能說明
|
||||
- **頁面目的**: 在桌面環境中展示完整的道具商店,提供豐富的購物體驗
|
||||
- **進入條件**: 從主選單進入或遊戲中道具不足時引導進入
|
||||
- **退出條件**: 完成購買或返回其他功能模組
|
||||
|
||||
#### Web版佈局特點
|
||||
- **商品展示區**: 占螢幕75%,網格式布局顯示商品
|
||||
- **篩選側邊欄**: 左側20%,包含分類、價格、評分等篩選
|
||||
- **購物車側邊欄**: 右側15%,顯示已選商品和快速結帳
|
||||
- **頂部導航列**: 包含搜索、分類導航、用戶餘額等
|
||||
- **底部推薦區**: 個性化推薦和熱門商品
|
||||
|
||||
#### 頁面欄位細節
|
||||
|
||||
| 欄位名稱 | 資料類型 | 必填 | 預設值 | 驗證規則 | 顯示條件 |
|
||||
|---------|---------|------|--------|----------|----------|
|
||||
| 商品列表 | Array | 是 | [] | 商品陣列 | 網格式布局顯示 |
|
||||
| 商品分類 | Array | 是 | [] | 分類陣列 | 左側篩選區 |
|
||||
| 用戶鑽石餘額 | Number | 是 | 0 | >=0 | 頂部餘額顯示 |
|
||||
| 用戶學習幣餘額 | Number | 是 | 0 | >=0 | 頂部餘額顯示 |
|
||||
| 購物車商品數 | Number | 是 | 0 | >=0 | 購物車圖示數字 |
|
||||
| 搜索關鍵詞 | String | 否 | - | 1-50字 | 搜索框 |
|
||||
| 篩選條件 | Object | 否 | {} | 篩選物件 | 側邊欄篩選器 |
|
||||
| 排序方式 | String | 是 | "recommended" | 排序枚舉 | 排序下拉選單 |
|
||||
| 分頁資訊 | Object | 是 | {} | 分頁物件 | 底部分頁器 |
|
||||
| 促銷活動 | Array | 否 | [] | 活動陣列 | 頂部橫幅 |
|
||||
|
||||
#### Web版互動元素
|
||||
|
||||
| 元素名稱 | 元素類型 | 操作方式 | 快捷鍵 | 狀態變化 | 備註 |
|
||||
|---------|---------|----------|--------|----------|------|
|
||||
| 商品卡片 | 卡片組件 | 點擊/Enter | Enter | 正常→選中 | 顯示商品詳情 |
|
||||
| 加入購物車 | 按鈕 | 點擊/A鍵 | A | 正常→已添加 | 動畫效果反饋 |
|
||||
| 立即購買 | 按鈕 | 點擊/B鍵 | B | 正常→處理中 | 跳轉結帳頁面 |
|
||||
| 搜索商品 | 搜索框 | 輸入/Ctrl+F | Ctrl+F | - | 即時搜索建議 |
|
||||
| 篩選器 | 複選框組 | 點擊/F鍵 | F | 未選→已選 | 即時更新商品列表 |
|
||||
| 排序選擇 | 下拉選單 | 點擊/S鍵 | S | - | 價格、評分、熱門度 |
|
||||
| 購物車展開 | 按鈕 | 點擊/C鍵 | C | 收合→展開 | 側邊欄滑出效果 |
|
||||
| 商品預覽 | 懸停效果 | 滑鼠懸停 | - | - | 快速預覽商品資訊 |
|
||||
| 批量選擇 | 複選框 | Ctrl+點擊 | Ctrl+A | - | 多選商品批量操作 |
|
||||
|
||||
#### Web版使用者操作流程
|
||||
1. **商品瀏覽**: 頁面載入 → 瀏覽商品類別 → 使用篩選和搜索功能
|
||||
2. **商品選擇**: 查看商品詳情 → 比較不同選項 → 加入購物車或立即購買
|
||||
3. **購物車管理**: 查看購物車 → 調整數量 → 應用優惠碼 → 計算總價
|
||||
4. **結帳流程**: 選擇支付方式 → 確認訂單 → 完成付款 → 獲得商品
|
||||
|
||||
### Page_Shopping_Cart_W - 購物車頁面 (Web版)
|
||||
|
||||
#### 功能說明
|
||||
- **頁面目的**: 管理選中的商品,計算價格,處理優惠和結帳
|
||||
- **進入條件**: 從商店點擊購物車或快捷鍵C
|
||||
- **退出條件**: 完成結帳或返回商店繼續購物
|
||||
|
||||
#### Web版特有功能
|
||||
- **價格明細**: 詳細的價格計算和優惠明細
|
||||
- **數量調整**: 可直接在購物車中調整商品數量
|
||||
- **優惠碼**: 支援優惠碼輸入和自動應用
|
||||
- **保存購物車**: 可保存購物車供下次購買
|
||||
|
||||
#### 頁面欄位細節
|
||||
|
||||
| 欄位名稱 | 資料類型 | 必填 | 預設值 | 驗證規則 | 顯示條件 |
|
||||
|---------|---------|------|--------|----------|----------|
|
||||
| 購物車商品列表 | Array | 是 | [] | 商品陣列 | 主要列表區域 |
|
||||
| 商品小計 | Number | 是 | 0 | >=0 | 每個商品行 |
|
||||
| 總金額 | Number | 是 | 0 | >=0 | 底部總計 |
|
||||
| 優惠折扣 | Number | 是 | 0 | >=0 | 優惠明細 |
|
||||
| 應付金額 | Number | 是 | 0 | >=0 | 最終金額 |
|
||||
| 優惠碼輸入 | String | 否 | - | 優惠碼格式 | 優惠碼輸入框 |
|
||||
| 支付方式選擇 | String | 是 | "diamonds" | 支付方式枚舉 | 支付方式選擇器 |
|
||||
| 配送方式 | String | 否 | - | 配送枚舉 | 實體商品時顯示 |
|
||||
|
||||
### Page_Subscription_Management_W - 訂閱管理中心 (Web專用)
|
||||
|
||||
#### 功能說明
|
||||
- **頁面目的**: 管理所有訂閱服務,包括續費、取消、升級等操作
|
||||
- **進入條件**: 從用戶設定或商店的訂閱區進入
|
||||
- **退出條件**: 完成訂閱管理或返回其他頁面
|
||||
|
||||
#### Web專有管理功能
|
||||
- **訂閱概覽**: 所有訂閱服務的統一管理界面
|
||||
- **自動續費控制**: 可開啟或關閉自動續費功能
|
||||
- **升級降級**: 訂閱方案的彈性調整
|
||||
- **付款歷史**: 完整的訂閱付款記錄
|
||||
- **發票下載**: 訂閱相關發票的下載功能
|
||||
|
||||
#### 頁面欄位細節
|
||||
|
||||
| 欄位名稱 | 資料類型 | 必填 | 預設值 | 驗證規則 | 顯示條件 |
|
||||
|---------|---------|------|--------|----------|----------|
|
||||
| 當前訂閱列表 | Array | 是 | [] | 訂閱陣列 | 主要列表顯示 |
|
||||
| 訂閱狀態 | String | 是 | - | 狀態枚舉 | 狀態標籤 |
|
||||
| 下次扣款日期 | Date | 否 | - | 未來日期 | 日期顯示 |
|
||||
| 下次扣款金額 | Number | 是 | 0 | >=0 | 金額顯示 |
|
||||
| 自動續費開關 | Boolean | 是 | true | true/false | 開關控件 |
|
||||
| 付款方式 | String | 是 | - | 付款方式枚舉 | 付款方式顯示 |
|
||||
| 優惠到期日 | Date | 否 | - | 日期 | 有優惠時顯示 |
|
||||
|
||||
## 🌐 Web端技術特點
|
||||
|
||||
### 電商級購物體驗
|
||||
- **購物車持久化**: 使用LocalStorage保存購物車狀態
|
||||
- **商品比較**: 支援多商品的特性對比功能
|
||||
- **心願單**: 商品收藏和心願單功能
|
||||
- **推薦系統**: 基於瀏覽和購買歷史的智能推薦
|
||||
|
||||
### 支付系統整合
|
||||
- **多重支付**: 支援信用卡、PayPal、Apple Pay等
|
||||
- **安全支付**: PCI DSS合規的支付處理
|
||||
- **訂閱計費**: 自動續費和訂閱管理
|
||||
- **退款處理**: 自動化退款流程
|
||||
|
||||
### 數據分析功能
|
||||
- **購買分析**: 用戶購買行為的詳細分析
|
||||
- **A/B測試**: 商品展示方式的A/B測試支援
|
||||
- **轉換追蹤**: 從瀏覽到購買的轉換漏斗分析
|
||||
- **價格敏感度**: 用戶對價格變化的反應分析
|
||||
|
||||
## ⌨️ Web版快捷鍵系統
|
||||
|
||||
### 商店瀏覽快捷鍵
|
||||
- `Ctrl + F` - 搜索商品
|
||||
- `F` - 開啟/關閉篩選器
|
||||
- `S` - 開啟排序選項
|
||||
- `C` - 查看購物車
|
||||
- `Enter` - 查看選中商品詳情
|
||||
- `A` - 加入購物車
|
||||
- `B` - 立即購買
|
||||
|
||||
### 購物車管理快捷鍵
|
||||
- `+/-` - 調整商品數量
|
||||
- `Delete` - 移除選中商品
|
||||
- `Ctrl + A` - 全選商品
|
||||
- `Ctrl + D` - 清空購物車
|
||||
- `Enter` - 前往結帳
|
||||
|
||||
### 訂閱管理快捷鍵
|
||||
- `U` - 升級訂閱
|
||||
- `D` - 降級訂閱
|
||||
- `P` - 暫停訂閱
|
||||
- `R` - 恢復訂閱
|
||||
- `Ctrl + P` - 列印發票
|
||||
- `Ctrl + E` - 匯出付款歷史
|
||||
|
||||
## 📊 Web版業務邏輯差異
|
||||
|
||||
### 定價策略靈活性
|
||||
- **動態定價**: 基於用戶行為和市場需求的動態定價
|
||||
- **地區定價**: 根據用戶所在地區調整價格
|
||||
- **企業折扣**: 針對企業用戶的批量折扣方案
|
||||
- **學生優惠**: 學生身份驗證的特殊優惠價格
|
||||
|
||||
### 購買流程優化
|
||||
- **一鍵結帳**: 常購商品的快速結帳流程
|
||||
- **分期付款**: 大額購買的分期付款選項
|
||||
- **群組購買**: 多人合併購買的折扣機制
|
||||
- **預購功能**: 新商品的預購和預付功能
|
||||
|
||||
### 庫存管理升級
|
||||
- **實時庫存**: 商品庫存的實時更新和顯示
|
||||
- **缺貨通知**: 缺貨商品的補貨通知功能
|
||||
- **限時優惠**: 時間限制的特價商品
|
||||
- **VIP專享**: 會員專屬商品和提前購買權
|
||||
|
||||
## 🧪 Web版測試要點
|
||||
|
||||
### 購物流程測試
|
||||
- [ ] 商品加入購物車流程正常
|
||||
- [ ] 購物車數量調整正確
|
||||
- [ ] 優惠碼應用計算準確
|
||||
- [ ] 結帳流程完整無誤
|
||||
- [ ] 支付流程安全可靠
|
||||
|
||||
### 訂閱系統測試
|
||||
- [ ] 訂閱購買流程正常
|
||||
- [ ] 自動續費機制正確
|
||||
- [ ] 訂閱升級降級功能正常
|
||||
- [ ] 取消訂閱流程完整
|
||||
- [ ] 發票生成功能正確
|
||||
|
||||
### 價格計算測試
|
||||
- [ ] 商品價格顯示正確
|
||||
- [ ] 折扣計算準確
|
||||
- [ ] 稅費計算正確 (如適用)
|
||||
- [ ] 匯率轉換準確 (多貨幣)
|
||||
- [ ] 組合優惠計算正確
|
||||
|
||||
### 支付安全測試
|
||||
- [ ] 信用卡資訊加密傳輸
|
||||
- [ ] 支付頁面HTTPS保護
|
||||
- [ ] 敏感資訊不在前端儲存
|
||||
- [ ] 支付失敗處理機制
|
||||
- [ ] 退款流程安全性
|
||||
|
||||
## 📝 Web端開發注意事項
|
||||
|
||||
### 前端架構
|
||||
- 使用現代電商框架 (Next.js/Nuxt.js)
|
||||
- 狀態管理使用Redux Toolkit或Zustand
|
||||
- 支付整合使用Stripe或PayPal SDK
|
||||
- 圖片最佳化使用WebP和懶載入
|
||||
|
||||
### 安全考量
|
||||
- 所有支付相關請求使用HTTPS
|
||||
- 信用卡資訊絕不在前端儲存
|
||||
- 實施CSP (Content Security Policy)
|
||||
- 定期安全漏洞掃描和修復
|
||||
|
||||
### 效能最佳化
|
||||
- 商品圖片使用CDN加速
|
||||
- 商品列表使用虛擬滾動
|
||||
- 購物車狀態使用本地存儲
|
||||
- 結帳頁面預載必要資源
|
||||
|
||||
### 合規要求
|
||||
- GDPR資料保護合規
|
||||
- PCI DSS支付卡資料安全
|
||||
- 各國稅務法規遵循
|
||||
- 消費者權益保護法律遵循
|
||||
|
||||
---
|
||||
|
||||
**文檔狀態**: 🟢 已完成
|
||||
**最後更新**: 2025-09-09
|
||||
**版本**: v1.0
|
||||
**相關文檔**:
|
||||
- `../mobile/04_道具商店功能規格.md` - 對應的Mobile版規格
|
||||
- `../common/業務規則.md` - 共同業務邏輯
|
||||
- `../common/數據模型.md` - 數據結構定義
|
||||
- `../common/API規格.md` - API接口規格
|
||||
|
|
@ -1,191 +0,0 @@
|
|||
# 平台功能對應表
|
||||
|
||||
## 📋 概述
|
||||
|
||||
**文檔名稱**: Mobile端與Web端功能對應表
|
||||
**建立日期**: 2025-09-09
|
||||
**最後更新**: 2025-09-09
|
||||
**維護團隊**: 產品/設計/開發
|
||||
|
||||
本文檔記錄了Mobile App和Web App之間的功能對應關係、平台差異和UI元素映射。
|
||||
|
||||
## 📱 UI命名對應表
|
||||
|
||||
### 詞彙學習功能
|
||||
| Mobile端 (UI_*) | Web端 (Page_*_W) | 功能對應度 | 平台差異 |
|
||||
|-----------------|------------------|------------|----------|
|
||||
| `UI_Vocab_Introduction` | `Page_Vocab_Introduction_W` | 95% | Web版增加筆記功能和多列布局 |
|
||||
| `UI_Vocab_Choice_Practice` | `Page_Vocab_Choice_Practice_W` | 98% | Web版增加快捷鍵支援 |
|
||||
| `UI_Vocab_Fluency_Matching` | `Page_Vocab_Fluency_Matching_W` | 90% | Web版改為滑鼠拖放操作 |
|
||||
| `UI_Vocab_Fluency_Reorganize` | `Page_Vocab_Fluency_Reorganize_W` | 92% | Web版支援鍵盤輸入 |
|
||||
| `UI_Vocab_Review_Main` | `Page_Vocab_Review_Main_W` | 85% | Web版增加批量操作 |
|
||||
| `UI_Vocab_Choice_Results` | `Page_Vocab_Choice_Results_W` | 100% | 功能完全相同 |
|
||||
| `UI_Vocab_Fluency_Results` | `Page_Vocab_Fluency_Results_W` | 80% | Web版增加詳細統計 |
|
||||
| `UI_Vocab_Sentence_Results` | `Page_Vocab_Sentence_Results_W` | 100% | 功能完全相同 |
|
||||
| - | `Page_Vocab_Analytics_Dashboard_W` | N/A | Web專用高級分析頁面 |
|
||||
|
||||
### 情境對話功能
|
||||
| Mobile端 (UI_*) | Web端 (Page_*_W) | 功能對應度 | 平台差異 |
|
||||
|-----------------|------------------|------------|----------|
|
||||
| `UI_Dialogue_Main` | `Page_Dialogue_Main_W` | 85% | Web版增加多窗格布局 |
|
||||
| `UI_Dialogue_Analysis` | `Page_Dialogue_Analysis_W` | 90% | Web版增加詳細圖表 |
|
||||
| `UI_Character_Details` | `Page_Character_Details_W` | 100% | 功能完全相同 |
|
||||
| `UI_Keywords_Details` | `Page_Keywords_Details_W` | 95% | Web版增加快速查詢 |
|
||||
| `UI_Reply_Input` | `Page_Reply_Input_W` | 70% | Web版支援實體鍵盤輸入 |
|
||||
| `UI_Reply_Assistance` | `Page_Reply_Assistance_W` | 80% | Web版側邊欄顯示 |
|
||||
|
||||
### 學習地圖功能
|
||||
| Mobile端 (UI_*) | Web端 (Page_*_W) | 功能對應度 | 平台差異 |
|
||||
|-----------------|------------------|------------|----------|
|
||||
| `UI_Map_Overview` | `Page_Map_Overview_W` | 75% | Web版支援縮放和全景視圖 |
|
||||
| `UI_Map_Level_Details` | `Page_Map_Level_Details_W` | 90% | Web版增加進度對比 |
|
||||
| `UI_Map_Progress_Display` | `Page_Map_Progress_Display_W` | 85% | Web版增加統計圖表 |
|
||||
| `UI_Achievement_Gallery` | `Page_Achievement_Gallery_W` | 95% | Web版增加搜索篩選 |
|
||||
|
||||
### 道具商店功能
|
||||
| Mobile端 (UI_*) | Web端 (Page_*_W) | 功能對應度 | 平台差異 |
|
||||
|-----------------|------------------|------------|----------|
|
||||
| `UI_Shop_Main` | `Page_Shop_Main_W` | 90% | Web版網格式布局 |
|
||||
| `UI_Shop_Item_Details` | `Page_Shop_Item_Details_W` | 100% | 功能完全相同 |
|
||||
| `UI_Shop_Item_Confirm` | `Page_Shop_Item_Confirm_W` | 100% | 功能完全相同 |
|
||||
| `UI_Inventory_Main` | `Page_Inventory_Main_W` | 85% | Web版增加批量操作 |
|
||||
| `UI_Purchase_History` | `Page_Purchase_History_W` | 95% | Web版增加篩選和匯出 |
|
||||
|
||||
### 用戶認證功能
|
||||
| Mobile端 (UI_*) | Web端 (Page_*_W) | 功能對應度 | 平台差異 |
|
||||
|-----------------|------------------|------------|----------|
|
||||
| `UI_Login_Main` | `Page_Login_Main_W` | 95% | Web版增加記住登入狀態 |
|
||||
| `UI_Register_Main` | `Page_Register_Main_W` | 95% | Web版增加即時驗證提示 |
|
||||
| `UI_Profile_Main` | `Page_Profile_Main_W` | 80% | Web版增加詳細設定選項 |
|
||||
| `UI_Settings_Main` | `Page_Settings_Main_W` | 70% | Web版增加高級設定 |
|
||||
| - | `Page_Privacy_Settings_W` | N/A | Web專用隱私設定頁面 |
|
||||
|
||||
## 🎯 互動方式對應表
|
||||
|
||||
### 基本操作對應
|
||||
| 操作類型 | Mobile端 | Web端 | 對應度 | 備註 |
|
||||
|---------|----------|-------|--------|------|
|
||||
| 確認操作 | 點擊按鈕 | 點擊按鈕/Enter鍵 | 100% | Web增加鍵盤支援 |
|
||||
| 取消操作 | 返回按鈕 | 取消按鈕/Esc鍵 | 100% | Web增加快捷鍵 |
|
||||
| 導航操作 | 底部標籤 | 頂部導航/側邊欄 | 80% | 布局差異 |
|
||||
| 搜索功能 | 搜索框 | 搜索框/Ctrl+F | 95% | Web增加高級搜索 |
|
||||
| 音頻播放 | 點擊播放 | 點擊播放/Space鍵 | 100% | Web增加快捷鍵 |
|
||||
|
||||
### 學習互動對應
|
||||
| 互動類型 | Mobile端 | Web端 | 對應度 | 技術實現差異 |
|
||||
|---------|----------|-------|--------|-------------|
|
||||
| 選擇題答題 | 點擊選項 | 點擊選項/鍵盤A-D | 100% | Web增加鍵盤操作 |
|
||||
| 圖片匹配 | 觸控拖拽 | 滑鼠拖拽 | 95% | 操作方式不同 |
|
||||
| 文字輸入 | 螢幕鍵盤 | 實體鍵盤 | 85% | 輸入體驗差異 |
|
||||
| 語音輸入 | 長按錄音 | 點擊錄音 | 90% | 操作手勢差異 |
|
||||
| 手勢操作 | 滑動翻頁 | 鍵盤方向鍵 | 80% | 操作方式完全不同 |
|
||||
|
||||
### 視覺回饋對應
|
||||
| 回饋類型 | Mobile端 | Web端 | 對應度 | 實現差異 |
|
||||
|---------|----------|-------|--------|---------|
|
||||
| 觸控回饋 | 震動/視覺 | 視覺/音效 | 70% | Mobile有觸覺回饋 |
|
||||
| 動畫效果 | 原生動畫 | CSS/JS動畫 | 95% | 技術實現不同 |
|
||||
| 狀態提示 | Toast訊息 | 通知橫條/Modal | 90% | 顯示方式略異 |
|
||||
| 進度指示 | 圓形進度條 | 線性/圓形進度條 | 100% | 樣式選擇更多 |
|
||||
| 錯誤提示 | 彈窗/頁面 | 內聯提示/Modal | 85% | 顯示位置不同 |
|
||||
|
||||
## 🚀 平台專有功能
|
||||
|
||||
### Mobile端專有功能
|
||||
| 功能名稱 | 說明 | 技術依賴 | Web端替代方案 |
|
||||
|---------|------|----------|-------------|
|
||||
| 觸覺回饋 | 震動回饋答對/答錯 | 設備硬體 | 音效/視覺回饋 |
|
||||
| 推播通知 | 學習提醒通知 | FCM/APNS | 瀏覽器通知API |
|
||||
| 重力感應 | 搖一搖操作 | 重力感應器 | 鍵盤快捷鍵 |
|
||||
| 相機掃描 | 掃描實體書籍 | 相機API | 圖片上傳識別 |
|
||||
| 離線學習 | 完全離線功能 | 本地儲存 | Service Worker快取 |
|
||||
| 語音喚醒 | "Hey Drama"語音助手 | 語音喚醒API | 手動啟動 |
|
||||
|
||||
### Web端專有功能
|
||||
| 功能名稱 | 說明 | 技術依賴 | Mobile端支援度 |
|
||||
|---------|------|----------|---------------|
|
||||
| 多標籤學習 | 同時開啟多個學習模組 | 瀏覽器標籤 | 不支援 |
|
||||
| 快捷鍵系統 | 完整鍵盤快捷鍵 | 鍵盤事件API | 部分支援 |
|
||||
| 數據匯出 | CSV/PDF匯出功能 | File API | 不支援 |
|
||||
| 列印優化 | 學習報告列印 | CSS Print | 不適用 |
|
||||
| 瀏覽器整合 | 書籤/歷史同步 | 瀏覽器API | 不適用 |
|
||||
| 多螢幕支援 | 多顯示器最佳化 | Screen API | 不適用 |
|
||||
| 即時協作 | 多人同時學習 | WebRTC/WebSocket | 技術上可行 |
|
||||
|
||||
## ⚖️ 功能優先級對應
|
||||
|
||||
### 核心功能 (必須在所有平台實現)
|
||||
| 功能類別 | Mobile實現度 | Web實現度 | 優先級 | 備註 |
|
||||
|---------|------------|----------|--------|------|
|
||||
| 用戶認證 | 100% | 100% | 🔥 最高 | 基礎功能 |
|
||||
| 詞彙學習 | 100% | 95% | 🔥 最高 | 核心功能 |
|
||||
| 對話練習 | 100% | 90% | 🔥 最高 | 核心功能 |
|
||||
| 學習進度 | 100% | 100% | 🔥 最高 | 用戶體驗 |
|
||||
| 基礎統計 | 100% | 100% | 🔥 最高 | 學習追蹤 |
|
||||
|
||||
### 重要功能 (推薦在所有平台實現)
|
||||
| 功能類別 | Mobile實現度 | Web實現度 | 優先級 | 備註 |
|
||||
|---------|------------|----------|--------|------|
|
||||
| 道具系統 | 100% | 95% | ⚠️ 重要 | 遊戲化體驗 |
|
||||
| 成就系統 | 100% | 100% | ⚠️ 重要 | 激勵機制 |
|
||||
| 社交分享 | 90% | 80% | ⚠️ 重要 | 用戶增長 |
|
||||
| 離線支援 | 100% | 70% | ⚠️ 重要 | 使用便利性 |
|
||||
| 音頻播放 | 100% | 95% | ⚠️ 重要 | 學習體驗 |
|
||||
|
||||
### 選擇性功能 (可根據平台特性選擇實現)
|
||||
| 功能類別 | Mobile實現度 | Web實現度 | 優先級 | 實現建議 |
|
||||
|---------|------------|----------|--------|---------|
|
||||
| 高級統計 | 60% | 100% | 📝 一般 | Web端優先 |
|
||||
| 數據匯出 | 0% | 100% | 📝 一般 | Web端專有 |
|
||||
| 多標籤 | 0% | 100% | 📝 一般 | Web端專有 |
|
||||
| 觸覺回饋 | 100% | 0% | 📝 一般 | Mobile端專有 |
|
||||
| 推播通知 | 100% | 60% | 📝 一般 | Mobile端優先 |
|
||||
|
||||
## 🔄 開發同步策略
|
||||
|
||||
### 功能開發優先序
|
||||
1. **第一階段**: 核心功能在兩平台同步開發
|
||||
2. **第二階段**: 重要功能優先Mobile端,再適配Web端
|
||||
3. **第三階段**: 平台專有功能獨立開發
|
||||
|
||||
### 代碼複用策略
|
||||
- **共用業務邏輯**: API呼叫、數據處理邏輯
|
||||
- **分離UI層**: 平台特定的互動和視覺設計
|
||||
- **統一數據模型**: 跨平台一致的數據結構
|
||||
- **共用工具函數**: 驗證、格式化等通用功能
|
||||
|
||||
### 測試策略對應
|
||||
- **功能測試**: 確保對應功能在兩平台行為一致
|
||||
- **UI測試**: 驗證平台特定的互動體驗
|
||||
- **整合測試**: 確保跨平台數據同步正確
|
||||
- **效能測試**: 各平台最佳化目標不同
|
||||
|
||||
## 📊 效能目標對應
|
||||
|
||||
### 載入效能目標
|
||||
| 指標 | Mobile端目標 | Web端目標 | 備註 |
|
||||
|------|-------------|----------|------|
|
||||
| 首屏載入 | < 2秒 | < 3秒 | 網路條件差異 |
|
||||
| 頁面切換 | < 500ms | < 200ms | 硬體效能差異 |
|
||||
| 音頻播放 | < 200ms | < 100ms | 快取策略不同 |
|
||||
| 圖片載入 | < 1秒 | < 800ms | CDN最佳化 |
|
||||
|
||||
### 記憶體使用目標
|
||||
| 資源類型 | Mobile端 | Web端 | 策略差異 |
|
||||
|---------|---------|-------|---------|
|
||||
| 基礎記憶體 | < 50MB | < 100MB | 瀏覽器overhead |
|
||||
| 音頻快取 | < 20MB | < 50MB | 儲存容量差異 |
|
||||
| 圖片快取 | < 30MB | < 100MB | 螢幕解析度差異 |
|
||||
| 學習資料 | < 10MB | < 20MB | 本地資料庫大小 |
|
||||
|
||||
---
|
||||
|
||||
**文檔狀態**: 🟢 已完成
|
||||
**最後更新**: 2025-09-09
|
||||
**版本**: v1.0
|
||||
**維護週期**: 每月檢查更新
|
||||
**相關文檔**:
|
||||
- `mobile/` - Mobile端功能規格
|
||||
- `web/` - Web端功能規格
|
||||
- `common/` - 共同業務邏輯和數據模型
|
||||
- `/PROJECTS.md` - 開發進度追蹤
|
||||
|
|
@ -1,196 +0,0 @@
|
|||
# 🎭 Drama Ling HTML 原型系統
|
||||
|
||||
## 📖 概述
|
||||
|
||||
這是 Drama Ling 項目的 HTML 原型系統,用於在正式開發前確認頁面設計、交互流程和視覺效果。
|
||||
|
||||
## 🎯 使用目的
|
||||
|
||||
### ✅ 優勢
|
||||
- **視覺化確認**: 直接在瀏覽器中查看實際效果
|
||||
- **快速迭代**: HTML/CSS 修改比 Vue 組件更快速
|
||||
- **跨團隊溝通**: 設計師、產品經理、開發者都能直觀理解
|
||||
- **規格明確**: 避免開發階段的猜測和重工
|
||||
- **組件識別**: 清楚了解需要開發的可重用組件
|
||||
- **交互演示**: 展示表單驗證、動畫效果等互動功能
|
||||
|
||||
## 📁 目錄結構
|
||||
|
||||
```
|
||||
html-prototypes/
|
||||
├── index.html # 原型導航頁面
|
||||
├── assets/
|
||||
│ └── style.css # 全局樣式和設計系統
|
||||
├── pages/ # 主要頁面原型
|
||||
│ ├── home.html # 首頁
|
||||
│ ├── register.html # 註冊頁面 ✅
|
||||
│ ├── login.html # 登入頁面
|
||||
│ ├── dashboard.html # 學習儀表板
|
||||
│ ├── vocabulary.html # 詞彙學習
|
||||
│ ├── dialogue.html # 對話練習
|
||||
│ ├── roleplay.html # 角色扮演
|
||||
│ └── ...
|
||||
├── components/ # UI 組件展示
|
||||
│ ├── buttons.html # 按鈕組件
|
||||
│ ├── forms.html # 表單組件
|
||||
│ ├── cards.html # 卡片組件
|
||||
│ └── modals.html # 彈窗組件
|
||||
└── specs/ # 設計規格展示
|
||||
├── colors.html # 色彩規範
|
||||
├── typography.html # 字體規範
|
||||
├── spacing.html # 間距規範
|
||||
└── icons.html # 圖示規範
|
||||
```
|
||||
|
||||
## 🚀 開始使用
|
||||
|
||||
### 1. 打開導航頁面
|
||||
```bash
|
||||
open docs/02_design/html-prototypes/index.html
|
||||
```
|
||||
|
||||
### 2. 瀏覽頁面原型
|
||||
- 點擊導航卡片查看各個頁面原型
|
||||
- 每個頁面都有完整的樣式和基礎交互功能
|
||||
- 右上角有原型狀態標記
|
||||
|
||||
### 3. 測試交互功能
|
||||
- 表單驗證(即時反饋)
|
||||
- 密碼強度檢查
|
||||
- 響應式布局
|
||||
- 動畫效果
|
||||
|
||||
## 🎨 設計系統
|
||||
|
||||
### 色彩規範
|
||||
- **主要色**: `#00E5CC` (青綠色)
|
||||
- **輔助色**: `#8E44AD` (紫色)
|
||||
- **功能色**: 錯誤、警告、成功、資訊
|
||||
- **深色主題**: 完整的深色配色方案
|
||||
|
||||
### 字體系統
|
||||
- **主字體**: Inter, PingFang TC
|
||||
- **等寬字體**: JetBrains Mono
|
||||
- **尺寸階層**: xs, sm, base, lg, xl, 2xl, 3xl, 4xl
|
||||
|
||||
### 間距系統
|
||||
- **基礎單位**: 0.25rem (4px)
|
||||
- **階層**: 1, 2, 3, 4, 5, 6, 8, 10, 12, 16, 20
|
||||
|
||||
## 📝 原型狀態管理
|
||||
|
||||
### 狀態標記
|
||||
- 🟡 **Draft**: 草稿階段,待完善
|
||||
- 🟣 **Review**: 審查階段,等待反饋
|
||||
- 🟢 **Final**: 最終確認,可用於開發
|
||||
|
||||
### 更新流程
|
||||
1. 創建/修改 HTML 原型
|
||||
2. 測試所有交互功能
|
||||
3. 標記適當的狀態
|
||||
4. 團隊審查和反饋
|
||||
5. 最終確認後用於 Vue 開發
|
||||
|
||||
## 🔧 開發轉換指南
|
||||
|
||||
### 從 HTML 到 Vue 的轉換步驟
|
||||
|
||||
#### 1. 組件拆分
|
||||
```html
|
||||
<!-- HTML 原型中的卡片 -->
|
||||
<div class="card">
|
||||
<h3>標題</h3>
|
||||
<p>內容</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- 轉換為 Vue 組件 -->
|
||||
<template>
|
||||
<div class="card">
|
||||
<h3>{{ title }}</h3>
|
||||
<p>{{ content }}</p>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
#### 2. 樣式提取
|
||||
- 將 CSS 轉換為 SCSS
|
||||
- 使用設計 token (CSS 變量)
|
||||
- 組件化樣式管理
|
||||
|
||||
#### 3. 交互邏輯
|
||||
- JavaScript 函數轉換為 Vue 方法
|
||||
- 表單驗證轉換為 Vue 響應式數據
|
||||
- 事件處理整合 Vue 生命周期
|
||||
|
||||
#### 4. 數據流
|
||||
- 靜態內容轉換為響應式數據
|
||||
- 整合 API 調用
|
||||
- 狀態管理 (Pinia)
|
||||
|
||||
## 📋 檢查清單
|
||||
|
||||
### 新頁面原型創建時
|
||||
- [ ] 使用統一的樣式系統
|
||||
- [ ] 實現基礎交互功能
|
||||
- [ ] 響應式設計測試
|
||||
- [ ] 無障礙訪問考量
|
||||
- [ ] 瀏覽器兼容性測試
|
||||
- [ ] 添加原型狀態標記
|
||||
|
||||
### 準備轉換為 Vue 時
|
||||
- [ ] 識別可重用組件
|
||||
- [ ] 確認設計規格完整
|
||||
- [ ] 測試所有用戶流程
|
||||
- [ ] 整理設計 token
|
||||
- [ ] 準備資產文件 (圖片、圖標)
|
||||
|
||||
## 🌟 最佳實踐
|
||||
|
||||
### 1. 保持一致性
|
||||
- 使用統一的 CSS 變量
|
||||
- 遵循命名規範
|
||||
- 保持視覺風格一致
|
||||
|
||||
### 2. 注重細節
|
||||
- 微交互和動畫
|
||||
- 錯誤狀態處理
|
||||
- 載入狀態展示
|
||||
- 空狀態設計
|
||||
|
||||
### 3. 考慮實際場景
|
||||
- 真實數據長度
|
||||
- 網絡延遲情況
|
||||
- 錯誤處理流程
|
||||
- 邊界條件測試
|
||||
|
||||
### 4. 文檔記錄
|
||||
- 交互說明
|
||||
- 特殊需求註記
|
||||
- 技術實現提醒
|
||||
|
||||
## 🚀 下一步計劃
|
||||
|
||||
### 即將完成的頁面
|
||||
1. **登入頁面** - 用戶認證流程
|
||||
2. **首頁** - 產品展示和導航
|
||||
3. **學習儀表板** - 主要學習界面
|
||||
4. **詞彙學習** - 核心學習模組
|
||||
|
||||
### 未來擴展
|
||||
- 移動端特化版本
|
||||
- 深色/淺色主題切換
|
||||
- 多語言版本展示
|
||||
- 組件庫完整化
|
||||
|
||||
---
|
||||
|
||||
## 📞 聯絡資訊
|
||||
|
||||
如有問題或建議,請:
|
||||
- 查看具體頁面的註解說明
|
||||
- 參考設計規格文檔
|
||||
- 提出改進建議
|
||||
|
||||
**記住:原型的目標是確認需求,避免開發階段的大幅修改!** ✨
|
||||
|
|
@ -1,300 +0,0 @@
|
|||
/* Drama Ling HTML 原型樣式系統 */
|
||||
|
||||
/* 設計變量 */
|
||||
:root {
|
||||
/* 品牌色彩 */
|
||||
--primary-teal: #00E5CC;
|
||||
--primary-teal-light: #33E8D1;
|
||||
--primary-teal-dark: #00B3A0;
|
||||
|
||||
--secondary-purple: #8E44AD;
|
||||
--accent-violet: #9B59B6;
|
||||
|
||||
/* 功能色彩 */
|
||||
--error-red: #E74C3C;
|
||||
--warning-yellow: #F39C12;
|
||||
--success-green: #00E5CC;
|
||||
--info-cyan: #3498DB;
|
||||
|
||||
/* 深色主題 */
|
||||
--text-primary: #FFFFFF;
|
||||
--text-secondary: #B8BCC8;
|
||||
--text-tertiary: #7F8C8D;
|
||||
|
||||
--bg-primary: #2C3E50;
|
||||
--bg-secondary: #34495E;
|
||||
--bg-dark: #1A252F;
|
||||
--bg-card: #3A4A5C;
|
||||
--divider: #4A5568;
|
||||
|
||||
/* 字體系統 */
|
||||
--font-primary: 'Inter', 'PingFang TC', -apple-system, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'SF Mono', Monaco, monospace;
|
||||
|
||||
/* 字體大小 */
|
||||
--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;
|
||||
|
||||
/* 間距 */
|
||||
--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;
|
||||
--space-10: 2.5rem;
|
||||
--space-12: 3rem;
|
||||
--space-16: 4rem;
|
||||
|
||||
/* 圓角 */
|
||||
--radius-sm: 0.5rem;
|
||||
--radius-md: 0.75rem;
|
||||
--radius-lg: 1rem;
|
||||
--radius-xl: 1.5rem;
|
||||
|
||||
/* 陰影 */
|
||||
--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);
|
||||
}
|
||||
|
||||
/* 全局重置 */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-primary);
|
||||
background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 原型導航系統 */
|
||||
.prototype-nav {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-8) var(--space-6);
|
||||
}
|
||||
|
||||
.nav-header {
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-12);
|
||||
}
|
||||
|
||||
.nav-header h1 {
|
||||
font-size: var(--text-4xl);
|
||||
font-weight: 900;
|
||||
background: linear-gradient(135deg, var(--primary-teal), var(--secondary-purple));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.nav-header p {
|
||||
font-size: var(--text-lg);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.nav-section {
|
||||
margin-bottom: var(--space-12);
|
||||
}
|
||||
|
||||
.nav-section h2 {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-6);
|
||||
padding-bottom: var(--space-3);
|
||||
border-bottom: 2px solid var(--divider);
|
||||
}
|
||||
|
||||
.nav-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.nav-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--divider);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-6);
|
||||
text-decoration: none;
|
||||
color: var(--text-primary);
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.nav-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-xl);
|
||||
border-color: var(--primary-teal);
|
||||
background: linear-gradient(135deg, var(--bg-card) 0%, rgba(0, 229, 204, 0.1) 100%);
|
||||
}
|
||||
|
||||
.nav-card h3 {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 600;
|
||||
color: var(--primary-teal);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.nav-card p {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 響應式設計 */
|
||||
@media (max-width: 768px) {
|
||||
.prototype-nav {
|
||||
padding: var(--space-6) var(--space-4);
|
||||
}
|
||||
|
||||
.nav-header h1 {
|
||||
font-size: var(--text-3xl);
|
||||
}
|
||||
|
||||
.nav-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.nav-card {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
/* 通用組件樣式 */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-6);
|
||||
border: none;
|
||||
border-radius: var(--radius-lg);
|
||||
font-family: inherit;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: var(--primary-teal);
|
||||
color: var(--bg-dark);
|
||||
}
|
||||
|
||||
.btn--primary:hover {
|
||||
background: var(--primary-teal-light);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 25px rgba(0, 229, 204, 0.3);
|
||||
}
|
||||
|
||||
.btn--secondary {
|
||||
background: var(--secondary-purple);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn--outline {
|
||||
background: transparent;
|
||||
color: var(--primary-teal);
|
||||
border: 2px solid var(--primary-teal);
|
||||
}
|
||||
|
||||
.btn--outline:hover {
|
||||
background: var(--primary-teal);
|
||||
color: var(--bg-dark);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--divider);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-6);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: var(--space-4) var(--space-5);
|
||||
background: var(--bg-secondary);
|
||||
border: 2px solid var(--divider);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--text-base);
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-teal);
|
||||
box-shadow: 0 0 0 4px rgba(0, 229, 204, 0.15);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 頁面狀態指示 */
|
||||
.prototype-badge {
|
||||
display: inline-block;
|
||||
padding: var(--space-1) var(--space-3);
|
||||
background: var(--info-cyan);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-left: var(--space-2);
|
||||
}
|
||||
|
||||
.prototype-badge--draft {
|
||||
background: var(--warning-yellow);
|
||||
color: var(--bg-dark);
|
||||
}
|
||||
|
||||
.prototype-badge--review {
|
||||
background: var(--secondary-purple);
|
||||
}
|
||||
|
||||
.prototype-badge--final {
|
||||
background: var(--success-green);
|
||||
color: var(--bg-dark);
|
||||
}
|
||||
|
||||
/* 滾動條美化 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--primary-teal);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--primary-teal-light);
|
||||
}
|
||||
|
|
@ -1,150 +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 - HTML 原型導航</title>
|
||||
<link rel="stylesheet" href="assets/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="prototype-nav">
|
||||
<header class="nav-header">
|
||||
<h1>🎭 Drama Ling - HTML 原型系統</h1>
|
||||
<p>互動式頁面原型與設計規格</p>
|
||||
</header>
|
||||
|
||||
<section class="nav-section">
|
||||
<h2>📱 主要頁面</h2>
|
||||
<div class="nav-grid">
|
||||
<a href="pages/home.html" class="nav-card">
|
||||
<h3>首頁</h3>
|
||||
<p>產品介紹、功能特色、CTA</p>
|
||||
</a>
|
||||
|
||||
<a href="pages/register.html" class="nav-card">
|
||||
<h3>註冊頁面</h3>
|
||||
<p>用戶註冊表單與驗證</p>
|
||||
</a>
|
||||
|
||||
<a href="pages/login.html" class="nav-card">
|
||||
<h3>登入頁面</h3>
|
||||
<p>用戶登入與忘記密碼</p>
|
||||
</a>
|
||||
|
||||
<a href="pages/dashboard.html" class="nav-card">
|
||||
<h3>學習儀表板</h3>
|
||||
<p>學習進度、任務概覽</p>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="nav-section">
|
||||
<h2>📚 學習模組</h2>
|
||||
<div class="nav-grid">
|
||||
<a href="pages/vocabulary.html" class="nav-card">
|
||||
<h3>詞彙學習</h3>
|
||||
<p>單詞卡片、測驗、進度</p>
|
||||
</a>
|
||||
|
||||
<a href="pages/dialogue.html" class="nav-card">
|
||||
<h3>對話練習</h3>
|
||||
<p>AI 對話、錄音回放</p>
|
||||
</a>
|
||||
|
||||
<a href="pages/roleplay.html" class="nav-card">
|
||||
<h3>角色扮演</h3>
|
||||
<p>情境模擬、角色選擇</p>
|
||||
</a>
|
||||
|
||||
<a href="pages/pronunciation.html" class="nav-card">
|
||||
<h3>發音練習</h3>
|
||||
<p>語音識別、發音評分</p>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="nav-section">
|
||||
<h2>👤 用戶管理</h2>
|
||||
<div class="nav-grid">
|
||||
<a href="pages/profile.html" class="nav-card">
|
||||
<h3>個人檔案</h3>
|
||||
<p>用戶信息、學習統計</p>
|
||||
</a>
|
||||
|
||||
<a href="pages/progress.html" class="nav-card">
|
||||
<h3>學習進度</h3>
|
||||
<p>詳細進度、成就系統</p>
|
||||
</a>
|
||||
|
||||
<a href="pages/settings.html" class="nav-card">
|
||||
<h3>設定頁面</h3>
|
||||
<p>偏好設定、通知管理</p>
|
||||
</a>
|
||||
|
||||
<a href="pages/subscription.html" class="nav-card">
|
||||
<h3>訂閱管理</h3>
|
||||
<p>方案選擇、付費管理</p>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="nav-section">
|
||||
<h2>🧩 組件庫</h2>
|
||||
<div class="nav-grid">
|
||||
<a href="components/buttons.html" class="nav-card">
|
||||
<h3>按鈕組件</h3>
|
||||
<p>各種按鈕樣式與狀態</p>
|
||||
</a>
|
||||
|
||||
<a href="components/forms.html" class="nav-card">
|
||||
<h3>表單組件</h3>
|
||||
<p>輸入框、選單、驗證</p>
|
||||
</a>
|
||||
|
||||
<a href="components/cards.html" class="nav-card">
|
||||
<h3>卡片組件</h3>
|
||||
<p>內容卡片、資訊展示</p>
|
||||
</a>
|
||||
|
||||
<a href="components/modals.html" class="nav-card">
|
||||
<h3>彈窗組件</h3>
|
||||
<p>對話框、提示框、通知</p>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="nav-section">
|
||||
<h2>📋 設計規格</h2>
|
||||
<div class="nav-grid">
|
||||
<a href="specs/colors.html" class="nav-card">
|
||||
<h3>色彩規範</h3>
|
||||
<p>品牌色、功能色、漸層</p>
|
||||
</a>
|
||||
|
||||
<a href="specs/typography.html" class="nav-card">
|
||||
<h3>字體規範</h3>
|
||||
<p>字型、大小、行距</p>
|
||||
</a>
|
||||
|
||||
<a href="specs/spacing.html" class="nav-card">
|
||||
<h3>間距規範</h3>
|
||||
<p>邊距、內距、網格</p>
|
||||
</a>
|
||||
|
||||
<a href="specs/icons.html" class="nav-card">
|
||||
<h3>圖示規範</h3>
|
||||
<p>圖標庫、使用規則</p>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 簡單的導航統計
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const cards = document.querySelectorAll('.nav-card');
|
||||
console.log(`HTML 原型系統載入完成,共 ${cards.length} 個頁面/組件`);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,945 +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>
|
||||
<link rel="stylesheet" href="../assets/style.css">
|
||||
<style>
|
||||
.dashboard-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;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: rgba(0, 229, 204, 0.1);
|
||||
color: var(--primary-teal);
|
||||
}
|
||||
|
||||
.nav-item.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;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
.welcome-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.welcome-text h1 {
|
||||
font-size: var(--text-3xl);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.welcome-text p {
|
||||
font-size: var(--text-lg);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.streak-badge {
|
||||
background: var(--success-green);
|
||||
color: var(--bg-dark);
|
||||
padding: var(--space-3) var(--space-5);
|
||||
border-radius: var(--radius-xl);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* 統計卡片 */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: var(--space-6);
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--divider);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-6);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: linear-gradient(90deg, var(--primary-teal), var(--secondary-purple));
|
||||
}
|
||||
|
||||
.stat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.stat-title {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: rgba(0, 229, 204, 0.2);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: var(--text-3xl);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.stat-change {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--success-green);
|
||||
}
|
||||
|
||||
/* 主要內容區域 */
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: var(--space-8);
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
.content-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--divider);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.card-action {
|
||||
color: var(--primary-teal);
|
||||
text-decoration: none;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.card-action:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 學習進度 */
|
||||
.progress-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-4);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.progress-item:hover {
|
||||
border-color: var(--primary-teal);
|
||||
background: rgba(0, 229, 204, 0.05);
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.progress-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: linear-gradient(135deg, var(--primary-teal), var(--secondary-purple));
|
||||
border-radius: var(--radius-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
|
||||
.progress-details h3 {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.progress-details p {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
width: 120px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--primary-teal);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-percent {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 每日任務 */
|
||||
.task-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: var(--space-3);
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.task-item:hover {
|
||||
background: rgba(0, 229, 204, 0.05);
|
||||
}
|
||||
|
||||
.task-checkbox {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--divider);
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.task-checkbox.completed {
|
||||
background: var(--success-green);
|
||||
border-color: var(--success-green);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.task-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.task-title {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.task-description {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.task-reward {
|
||||
background: var(--secondary-purple);
|
||||
color: white;
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 推薦課程 */
|
||||
.course-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.course-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--divider);
|
||||
border-radius: var(--radius-xl);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.course-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-xl);
|
||||
border-color: var(--primary-teal);
|
||||
}
|
||||
|
||||
.course-image {
|
||||
height: 160px;
|
||||
background: linear-gradient(135deg, var(--primary-teal), var(--secondary-purple));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--text-4xl);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.course-content {
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.course-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.course-description {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.course-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.course-level {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.course-time {
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* 響應式設計 */
|
||||
@media (max-width: 1024px) {
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main-content {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.welcome-section {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.course-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.prototype-note {
|
||||
position: fixed;
|
||||
top: var(--space-4);
|
||||
right: var(--space-4);
|
||||
background: var(--success-green);
|
||||
color: var(--bg-dark);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
z-index: 1000;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.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) {
|
||||
.mobile-menu-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="prototype-note">
|
||||
🎯 HTML 原型 - 學習儀表板
|
||||
</div>
|
||||
|
||||
<button class="mobile-menu-btn" id="mobileMenuBtn">☰</button>
|
||||
|
||||
<div class="dashboard-layout">
|
||||
<!-- 側邊欄 -->
|
||||
<aside class="sidebar" 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 active">
|
||||
<span class="nav-icon">📊</span>
|
||||
學習儀表板
|
||||
</a>
|
||||
<a href="vocabulary.html" class="nav-item">
|
||||
<span class="nav-icon">📚</span>
|
||||
詞彙學習
|
||||
</a>
|
||||
<a href="dialogue.html" class="nav-item">
|
||||
<span class="nav-icon">💬</span>
|
||||
對話練習
|
||||
</a>
|
||||
<a href="roleplay.html" class="nav-item">
|
||||
<span class="nav-icon">🎭</span>
|
||||
角色扮演
|
||||
</a>
|
||||
<a href="pronunciation.html" class="nav-item">
|
||||
<span class="nav-icon">🎵</span>
|
||||
發音練習
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="nav-section">
|
||||
<div class="nav-section-title">個人管理</div>
|
||||
<a href="profile.html" class="nav-item">
|
||||
<span class="nav-icon">👤</span>
|
||||
個人檔案
|
||||
</a>
|
||||
<a href="progress.html" class="nav-item">
|
||||
<span class="nav-icon">📈</span>
|
||||
學習進度
|
||||
</a>
|
||||
<a href="settings.html" class="nav-item">
|
||||
<span class="nav-icon">⚙️</span>
|
||||
設定
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="nav-section">
|
||||
<div class="nav-section-title">訂閱服務</div>
|
||||
<a href="subscription.html" 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>
|
||||
|
||||
<!-- 主內容區 -->
|
||||
<main class="main-content">
|
||||
<div class="dashboard-header">
|
||||
<div class="welcome-section">
|
||||
<div class="welcome-text">
|
||||
<h1>早安,張小明!</h1>
|
||||
<p>今天也要繼續努力學習喔!</p>
|
||||
</div>
|
||||
<div class="streak-badge">
|
||||
🔥 連續學習 42 天
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 統計卡片 -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-header">
|
||||
<span class="stat-title">本週學習時間</span>
|
||||
<div class="stat-icon">⏰</div>
|
||||
</div>
|
||||
<div class="stat-value">12.5<span style="font-size: var(--text-lg); color: var(--text-secondary);">小時</span></div>
|
||||
<div class="stat-change">+2.3小時 vs 上週</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-header">
|
||||
<span class="stat-title">完成課程</span>
|
||||
<div class="stat-icon">✅</div>
|
||||
</div>
|
||||
<div class="stat-value">8<span style="font-size: var(--text-lg); color: var(--text-secondary);">/10</span></div>
|
||||
<div class="stat-change">本週目標進度 80%</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-header">
|
||||
<span class="stat-title">詞彙掌握</span>
|
||||
<div class="stat-icon">📚</div>
|
||||
</div>
|
||||
<div class="stat-value">1,247<span style="font-size: var(--text-lg); color: var(--text-secondary);">個</span></div>
|
||||
<div class="stat-change">+23 本週新增</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-header">
|
||||
<span class="stat-title">發音準確度</span>
|
||||
<div class="stat-icon">🎵</div>
|
||||
</div>
|
||||
<div class="stat-value">92<span style="font-size: var(--text-lg); color: var(--text-secondary);">%</span></div>
|
||||
<div class="stat-change">+5% 比上月提升</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-grid">
|
||||
<!-- 學習進度 -->
|
||||
<div class="content-card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">進行中的學習</h2>
|
||||
<a href="#" class="card-action">查看全部</a>
|
||||
</div>
|
||||
|
||||
<div class="progress-item">
|
||||
<div class="progress-info">
|
||||
<div class="progress-icon">☕</div>
|
||||
<div class="progress-details">
|
||||
<h3>咖啡廳情境對話</h3>
|
||||
<p>Level 3 • 日常對話</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: 75%"></div>
|
||||
</div>
|
||||
<div class="progress-percent">75%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-item">
|
||||
<div class="progress-info">
|
||||
<div class="progress-icon">🏢</div>
|
||||
<div class="progress-details">
|
||||
<h3>商務會議場景</h3>
|
||||
<p>Level 5 • 專業對話</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: 45%"></div>
|
||||
</div>
|
||||
<div class="progress-percent">45%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-item">
|
||||
<div class="progress-info">
|
||||
<div class="progress-icon">✈️</div>
|
||||
<div class="progress-details">
|
||||
<h3>機場旅行對話</h3>
|
||||
<p>Level 4 • 旅遊英語</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: 90%"></div>
|
||||
</div>
|
||||
<div class="progress-percent">90%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 每日任務 -->
|
||||
<div class="content-card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">今日任務</h2>
|
||||
<span style="font-size: var(--text-sm); color: var(--text-secondary);">3/5 完成</span>
|
||||
</div>
|
||||
|
||||
<div class="task-item">
|
||||
<div class="task-checkbox completed">✓</div>
|
||||
<div class="task-content">
|
||||
<div class="task-title">完成詞彙複習</div>
|
||||
<div class="task-description">複習20個單詞</div>
|
||||
</div>
|
||||
<div class="task-reward">+10 XP</div>
|
||||
</div>
|
||||
|
||||
<div class="task-item">
|
||||
<div class="task-checkbox completed">✓</div>
|
||||
<div class="task-content">
|
||||
<div class="task-title">對話練習</div>
|
||||
<div class="task-description">完成1次AI對話</div>
|
||||
</div>
|
||||
<div class="task-reward">+15 XP</div>
|
||||
</div>
|
||||
|
||||
<div class="task-item">
|
||||
<div class="task-checkbox completed">✓</div>
|
||||
<div class="task-content">
|
||||
<div class="task-title">發音練習</div>
|
||||
<div class="task-description">錄音練習5句話</div>
|
||||
</div>
|
||||
<div class="task-reward">+12 XP</div>
|
||||
</div>
|
||||
|
||||
<div class="task-item">
|
||||
<div class="task-checkbox">
|
||||
</div>
|
||||
<div class="task-content">
|
||||
<div class="task-title">角色扮演</div>
|
||||
<div class="task-description">完成餐廳場景</div>
|
||||
</div>
|
||||
<div class="task-reward">+20 XP</div>
|
||||
</div>
|
||||
|
||||
<div class="task-item">
|
||||
<div class="task-checkbox"></div>
|
||||
<div class="task-content">
|
||||
<div class="task-title">學習連續</div>
|
||||
<div class="task-description">保持學習紀錄</div>
|
||||
</div>
|
||||
<div class="task-reward">+5 XP</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 推薦課程 -->
|
||||
<div class="content-card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">為你推薦</h2>
|
||||
<a href="#" class="card-action">查看更多</a>
|
||||
</div>
|
||||
|
||||
<div class="course-grid">
|
||||
<div class="course-card">
|
||||
<div class="course-image">🍴</div>
|
||||
<div class="course-content">
|
||||
<h3 class="course-title">餐廳點餐對話</h3>
|
||||
<p class="course-description">學習在餐廳用餐時的各種實用對話,從預約到結帳。</p>
|
||||
<div class="course-meta">
|
||||
<span class="course-level">Level 3</span>
|
||||
<span class="course-time">25分鐘</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="course-card">
|
||||
<div class="course-image">🛍️</div>
|
||||
<div class="course-content">
|
||||
<h3 class="course-title">購物場景對話</h3>
|
||||
<p class="course-description">掌握購物時的詢價、比較、討價還價等實用語句。</p>
|
||||
<div class="course-meta">
|
||||
<span class="course-level">Level 2</span>
|
||||
<span class="course-time">30分鐘</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="course-card">
|
||||
<div class="course-image">🏥</div>
|
||||
<div class="course-content">
|
||||
<h3 class="course-title">醫院就診對話</h3>
|
||||
<p class="course-description">學習描述症狀、與醫生溝通的重要表達方式。</p>
|
||||
<div class="course-meta">
|
||||
<span class="course-level">Level 4</span>
|
||||
<span class="course-time">35分鐘</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 原型互動功能
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 行動版選單切換
|
||||
const mobileMenuBtn = document.getElementById('mobileMenuBtn');
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
|
||||
mobileMenuBtn.addEventListener('click', function() {
|
||||
sidebar.classList.toggle('open');
|
||||
});
|
||||
|
||||
// 點擊外部關閉側邊欄
|
||||
document.addEventListener('click', function(e) {
|
||||
if (window.innerWidth <= 1024 &&
|
||||
!sidebar.contains(e.target) &&
|
||||
!mobileMenuBtn.contains(e.target)) {
|
||||
sidebar.classList.remove('open');
|
||||
}
|
||||
});
|
||||
|
||||
// 任務checkbox互動
|
||||
document.querySelectorAll('.task-item').forEach(item => {
|
||||
const checkbox = item.querySelector('.task-checkbox');
|
||||
|
||||
item.addEventListener('click', function() {
|
||||
if (!checkbox.classList.contains('completed')) {
|
||||
checkbox.classList.add('completed');
|
||||
checkbox.textContent = '✓';
|
||||
|
||||
// 模擬獲得XP動畫
|
||||
const reward = item.querySelector('.task-reward');
|
||||
reward.style.animation = 'pulse 0.6s ease';
|
||||
|
||||
setTimeout(() => {
|
||||
alert('🎉 任務完成!獲得 ' + reward.textContent);
|
||||
}, 300);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 進度條動畫
|
||||
document.querySelectorAll('.progress-fill').forEach(fill => {
|
||||
const targetWidth = fill.style.width;
|
||||
fill.style.width = '0%';
|
||||
|
||||
setTimeout(() => {
|
||||
fill.style.width = targetWidth;
|
||||
}, 500);
|
||||
});
|
||||
|
||||
// 課程卡片點擊
|
||||
document.querySelectorAll('.course-card').forEach(card => {
|
||||
card.addEventListener('click', function() {
|
||||
const title = this.querySelector('.course-title').textContent;
|
||||
alert(`🎭 即將開始學習:${title}\n這會跳轉到對應的學習頁面`);
|
||||
});
|
||||
});
|
||||
|
||||
// 統計卡片hover效果
|
||||
document.querySelectorAll('.stat-card').forEach(card => {
|
||||
card.addEventListener('mouseenter', function() {
|
||||
this.style.transform = 'translateY(-2px)';
|
||||
this.style.boxShadow = 'var(--shadow-lg)';
|
||||
});
|
||||
|
||||
card.addEventListener('mouseleave', function() {
|
||||
this.style.transform = 'translateY(0)';
|
||||
this.style.boxShadow = 'var(--shadow-sm)';
|
||||
});
|
||||
});
|
||||
|
||||
// 導航項目點擊
|
||||
document.querySelectorAll('.nav-item').forEach(item => {
|
||||
item.addEventListener('click', function(e) {
|
||||
if (this.getAttribute('href') === '#') {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
// 更新active狀態
|
||||
document.querySelectorAll('.nav-item').forEach(nav => {
|
||||
nav.classList.remove('active');
|
||||
});
|
||||
this.classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
console.log('📊 學習儀表板原型已載入完成');
|
||||
});
|
||||
|
||||
// 響應式處理
|
||||
window.addEventListener('resize', function() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
if (window.innerWidth > 1024) {
|
||||
sidebar.classList.remove('open');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,736 +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>
|
||||
<link rel="stylesheet" href="../assets/style.css">
|
||||
<style>
|
||||
.hero-section {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: linear-gradient(135deg,
|
||||
var(--primary-teal) 0%,
|
||||
var(--secondary-purple) 50%,
|
||||
var(--accent-violet) 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-pattern {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-image:
|
||||
radial-gradient(circle at 15% 25%, rgba(255,255,255,0.1) 0%, transparent 50%),
|
||||
radial-gradient(circle at 85% 75%, rgba(255,255,255,0.1) 0%, transparent 50%),
|
||||
radial-gradient(circle at 45% 10%, rgba(255,255,255,0.05) 0%, transparent 50%);
|
||||
background-size: 400px 400px, 300px 300px, 500px 500px;
|
||||
animation: heroFloat 20s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes heroFloat {
|
||||
0%, 100% {
|
||||
transform: translateY(0px) rotate(0deg);
|
||||
}
|
||||
33% {
|
||||
transform: translateY(-15px) rotate(1deg);
|
||||
}
|
||||
66% {
|
||||
transform: translateY(10px) rotate(-1deg);
|
||||
}
|
||||
}
|
||||
|
||||
.hero-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-8) var(--space-6);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-12);
|
||||
align-items: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hero-content h1 {
|
||||
font-size: var(--text-4xl);
|
||||
font-weight: 900;
|
||||
color: white;
|
||||
margin-bottom: var(--space-6);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.hero-tagline {
|
||||
font-size: var(--text-xl);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin-bottom: var(--space-8);
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.hero-features {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
.feature-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.hero-cta {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cta-primary {
|
||||
background: white;
|
||||
color: var(--primary-teal);
|
||||
padding: var(--space-4) var(--space-8);
|
||||
border-radius: var(--radius-xl);
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.cta-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
|
||||
background: var(--primary-teal);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cta-secondary {
|
||||
background: transparent;
|
||||
color: white;
|
||||
border: 2px solid rgba(255, 255, 255, 0.4);
|
||||
padding: var(--space-4) var(--space-8);
|
||||
border-radius: var(--radius-xl);
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.cta-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: white;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.hero-visual {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.app-mockup {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-6);
|
||||
backdrop-filter: blur(20px);
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.mockup-screen {
|
||||
background: var(--bg-dark);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-6);
|
||||
color: white;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.mockup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.mockup-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: var(--primary-teal);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mockup-dialogue {
|
||||
background: var(--bg-secondary);
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.mockup-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.mockup-option {
|
||||
background: rgba(0, 229, 204, 0.2);
|
||||
border: 1px solid var(--primary-teal);
|
||||
padding: var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.mockup-option:hover {
|
||||
background: rgba(0, 229, 204, 0.3);
|
||||
}
|
||||
|
||||
/* 功能展示區 */
|
||||
.features-section {
|
||||
padding: var(--space-16) var(--space-6);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.features-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-12);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--text-3xl);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
font-size: var(--text-lg);
|
||||
color: var(--text-secondary);
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--divider);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-8);
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-xl);
|
||||
border-color: var(--primary-teal);
|
||||
}
|
||||
|
||||
.feature-icon-large {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: linear-gradient(135deg, var(--primary-teal), var(--secondary-purple));
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--text-3xl);
|
||||
margin: 0 auto var(--space-6);
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.feature-description {
|
||||
font-size: var(--text-base);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 統計數據區 */
|
||||
.stats-section {
|
||||
padding: var(--space-12) var(--space-6);
|
||||
background: linear-gradient(135deg,
|
||||
var(--secondary-purple) 0%,
|
||||
var(--accent-violet) 100%);
|
||||
}
|
||||
|
||||
.stats-container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: var(--space-8);
|
||||
margin-top: var(--space-8);
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: var(--text-4xl);
|
||||
font-weight: 900;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: var(--text-lg);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* CTA 區 */
|
||||
.cta-section {
|
||||
padding: var(--space-16) var(--space-6);
|
||||
background: var(--bg-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cta-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.cta-title {
|
||||
font-size: var(--text-3xl);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.cta-description {
|
||||
font-size: var(--text-lg);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
.cta-buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* 響應式設計 */
|
||||
@media (max-width: 768px) {
|
||||
.hero-container {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-8);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-content h1 {
|
||||
font-size: var(--text-3xl);
|
||||
}
|
||||
|
||||
.hero-cta {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.prototype-note {
|
||||
position: fixed;
|
||||
top: var(--space-4);
|
||||
right: var(--space-4);
|
||||
background: var(--success-green);
|
||||
color: var(--bg-dark);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
z-index: 1000;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* 導航列 */
|
||||
.nav-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(44, 62, 80, 0.9);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: var(--space-4) var(--space-6);
|
||||
z-index: 100;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
display: flex;
|
||||
gap: var(--space-6);
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.nav-menu a {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-menu a:hover {
|
||||
color: var(--primary-teal);
|
||||
}
|
||||
|
||||
.nav-actions {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-btn--login {
|
||||
color: white;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.nav-btn--login:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.nav-btn--signup {
|
||||
background: var(--primary-teal);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-btn--signup:hover {
|
||||
background: var(--primary-teal-light);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="prototype-note">
|
||||
🎯 HTML 原型 - 首頁
|
||||
</div>
|
||||
|
||||
<!-- 導航列 -->
|
||||
<nav class="nav-bar">
|
||||
<div class="nav-container">
|
||||
<a href="#" class="nav-logo">
|
||||
🎭 Drama Ling
|
||||
</a>
|
||||
|
||||
<ul class="nav-menu">
|
||||
<li><a href="#features">功能特色</a></li>
|
||||
<li><a href="#pricing">方案價格</a></li>
|
||||
<li><a href="#about">關於我們</a></li>
|
||||
<li><a href="#contact">聯絡我們</a></li>
|
||||
</ul>
|
||||
|
||||
<div class="nav-actions">
|
||||
<a href="login.html" class="nav-btn nav-btn--login">登入</a>
|
||||
<a href="register.html" class="nav-btn nav-btn--signup">免費試用</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 英雄區 -->
|
||||
<section class="hero-section">
|
||||
<div class="hero-pattern"></div>
|
||||
|
||||
<div class="hero-container">
|
||||
<div class="hero-content">
|
||||
<h1>用戲劇方式<br>學會真正的語言</h1>
|
||||
<p class="hero-tagline">
|
||||
透過角色扮演和情境對話,讓語言學習變得生動有趣,
|
||||
不再死記硬背,而是在真實場景中自然掌握。
|
||||
</p>
|
||||
|
||||
<div class="hero-features">
|
||||
<div class="feature-item">
|
||||
<div class="feature-icon">🎭</div>
|
||||
<span>沉浸式角色扮演學習</span>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<div class="feature-icon">🤖</div>
|
||||
<span>AI 智能對話練習</span>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<div class="feature-icon">🎯</div>
|
||||
<span>個人化學習進度</span>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<div class="feature-icon">🏆</div>
|
||||
<span>成就系統激勵學習</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero-cta">
|
||||
<a href="register.html" class="cta-primary">立即開始學習</a>
|
||||
<a href="#demo" class="cta-secondary">觀看演示</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero-visual">
|
||||
<div class="app-mockup">
|
||||
<div class="mockup-screen">
|
||||
<div class="mockup-header">
|
||||
<div class="mockup-avatar">👩</div>
|
||||
<div>
|
||||
<div style="font-weight: 600;">咖啡廳服務員</div>
|
||||
<div style="font-size: 0.8em; opacity: 0.7;">Level 3 對話</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mockup-dialogue">
|
||||
"歡迎光臨!請問您想要什麼飲料?"
|
||||
</div>
|
||||
|
||||
<div class="mockup-options">
|
||||
<div class="mockup-option">我想要一杯拿鐵咖啡</div>
|
||||
<div class="mockup-option">請給我看看菜單</div>
|
||||
<div class="mockup-option">你們有什麼推薦的嗎?</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 功能特色區 -->
|
||||
<section class="features-section" id="features">
|
||||
<div class="features-container">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">為什麼選擇 Drama Ling?</h2>
|
||||
<p class="section-subtitle">
|
||||
結合戲劇教學法和人工智能技術,創造最自然、最有效的語言學習體驗
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon-large">🎭</div>
|
||||
<h3 class="feature-title">角色扮演學習</h3>
|
||||
<p class="feature-description">
|
||||
扮演不同角色,在真實情境中練習對話。
|
||||
從餐廳點餐到商務談判,涵蓋生活各個場景。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon-large">🤖</div>
|
||||
<h3 class="feature-title">AI 智能對話</h3>
|
||||
<p class="feature-description">
|
||||
與AI進行自然對話,獲得即時反饋。
|
||||
智能調整難度,確保學習進度最佳化。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon-large">📊</div>
|
||||
<h3 class="feature-title">個人化學習</h3>
|
||||
<p class="feature-description">
|
||||
AI分析你的學習模式,制定專屬學習計劃。
|
||||
追蹤進度,調整內容難度。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon-large">🎵</div>
|
||||
<h3 class="feature-title">發音練習</h3>
|
||||
<p class="feature-description">
|
||||
語音識別技術評估發音準確度。
|
||||
提供詳細反饋,快速改善口說能力。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon-large">🏆</div>
|
||||
<h3 class="feature-title">成就系統</h3>
|
||||
<p class="feature-description">
|
||||
完成任務獲得成就徽章。
|
||||
排行榜競爭,讓學習充滿動力。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon-large">📱</div>
|
||||
<h3 class="feature-title">跨平台同步</h3>
|
||||
<p class="feature-description">
|
||||
手機、平板、電腦無縫同步。
|
||||
隨時隨地繼續你的學習進度。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 統計數據區 -->
|
||||
<section class="stats-section">
|
||||
<div class="stats-container">
|
||||
<h2 class="section-title" style="color: white;">數萬用戶的學習成果</h2>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">50,000+</div>
|
||||
<div class="stat-label">活躍學習者</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">95%</div>
|
||||
<div class="stat-label">用戶滿意度</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">200+</div>
|
||||
<div class="stat-label">學習場景</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">12</div>
|
||||
<div class="stat-label">支援語言</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA 區 -->
|
||||
<section class="cta-section">
|
||||
<div class="cta-container">
|
||||
<h2 class="cta-title">準備開始你的語言學習之旅?</h2>
|
||||
<p class="cta-description">
|
||||
加入數萬名學習者的行列,用最自然的方式掌握新語言。
|
||||
7天免費試用,隨時可以取消。
|
||||
</p>
|
||||
|
||||
<div class="cta-buttons">
|
||||
<a href="register.html" class="cta-primary">免費開始試用</a>
|
||||
<a href="#pricing" class="cta-secondary">查看方案價格</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
// 原型互動功能
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 導航列滾動效果
|
||||
const navbar = document.querySelector('.nav-bar');
|
||||
|
||||
window.addEventListener('scroll', function() {
|
||||
if (window.scrollY > 50) {
|
||||
navbar.style.background = 'rgba(44, 62, 80, 0.95)';
|
||||
} else {
|
||||
navbar.style.background = 'rgba(44, 62, 80, 0.9)';
|
||||
}
|
||||
});
|
||||
|
||||
// 平滑滾動到錨點
|
||||
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||
anchor.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
const targetId = this.getAttribute('href');
|
||||
const targetSection = document.querySelector(targetId);
|
||||
|
||||
if (targetSection) {
|
||||
targetSection.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 模擬對話選項點擊
|
||||
document.querySelectorAll('.mockup-option').forEach(option => {
|
||||
option.addEventListener('click', function() {
|
||||
// 重置所有選項
|
||||
document.querySelectorAll('.mockup-option').forEach(opt => {
|
||||
opt.style.background = 'rgba(0, 229, 204, 0.2)';
|
||||
});
|
||||
|
||||
// 高亮選中的選項
|
||||
this.style.background = 'rgba(0, 229, 204, 0.4)';
|
||||
|
||||
// 模擬回應
|
||||
setTimeout(() => {
|
||||
alert(`🎭 很好的選擇!在真實應用中,這會觸發AI回應和學習進度更新。`);
|
||||
}, 300);
|
||||
});
|
||||
});
|
||||
|
||||
// CTA 按鈕點擊追蹤
|
||||
document.querySelectorAll('.cta-primary').forEach(btn => {
|
||||
btn.addEventListener('click', function(e) {
|
||||
console.log('CTA 點擊:', this.textContent, '- 頁面:', window.location.pathname);
|
||||
});
|
||||
});
|
||||
|
||||
console.log('🏠 首頁原型已載入完成');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,496 +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>
|
||||
<link rel="stylesheet" href="../assets/style.css">
|
||||
<style>
|
||||
.auth-layout {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
background: linear-gradient(135deg,
|
||||
var(--primary-teal) 0%,
|
||||
var(--secondary-purple) 50%,
|
||||
var(--accent-violet) 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.auth-layout::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(44, 62, 80, 0.3);
|
||||
}
|
||||
|
||||
.auth-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;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0px) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-20px) rotate(2deg);
|
||||
}
|
||||
}
|
||||
|
||||
.auth-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.auth-branding {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: var(--space-8);
|
||||
color: white;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--text-4xl);
|
||||
margin-bottom: var(--space-6);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: var(--text-4xl);
|
||||
font-weight: 900;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 300;
|
||||
margin-bottom: var(--space-6);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.welcome-back {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
padding: var(--space-6);
|
||||
border-radius: var(--radius-xl);
|
||||
backdrop-filter: blur(10px);
|
||||
margin-top: var(--space-8);
|
||||
}
|
||||
|
||||
.welcome-back h3 {
|
||||
font-size: var(--text-xl);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.user-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--primary-teal);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: var(--text-sm);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.auth-form-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-8);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.login-form {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
color: var(--bg-dark);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-8);
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.form-header {
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--bg-dark);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.form-subtitle {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--bg-dark);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border: 1px solid #D1D5DB;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-base);
|
||||
color: var(--bg-dark);
|
||||
background: white;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-teal);
|
||||
box-shadow: 0 0 0 3px rgba(0, 229, 204, 0.1);
|
||||
}
|
||||
|
||||
.form-input.error {
|
||||
border-color: var(--error-red);
|
||||
}
|
||||
|
||||
.form-error {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--error-red);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.form-options {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.remember-me {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--bg-dark);
|
||||
}
|
||||
|
||||
.forgot-password {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--primary-teal);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.forgot-password:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
padding: var(--space-4) var(--space-6);
|
||||
background: var(--primary-teal);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
background: var(--primary-teal-light);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 25px rgba(0, 229, 204, 0.3);
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.divider {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
margin: var(--space-6) 0;
|
||||
}
|
||||
|
||||
.divider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.divider span {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 0 var(--space-4);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.google-btn {
|
||||
width: 100%;
|
||||
padding: var(--space-4) var(--space-6);
|
||||
background: white;
|
||||
color: var(--bg-dark);
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
margin-bottom: var(--space-6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.google-btn:hover {
|
||||
background: #f9fafb;
|
||||
border-color: var(--primary-teal);
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
text-align: center;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.form-footer a {
|
||||
color: var(--primary-teal);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 響應式設計 */
|
||||
@media (max-width: 768px) {
|
||||
.auth-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.auth-branding {
|
||||
padding: var(--space-6) var(--space-4);
|
||||
}
|
||||
|
||||
.auth-form-container {
|
||||
padding: var(--space-6) var(--space-4);
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: var(--text-3xl);
|
||||
}
|
||||
}
|
||||
|
||||
.prototype-note {
|
||||
position: fixed;
|
||||
top: var(--space-4);
|
||||
right: var(--space-4);
|
||||
background: var(--success-green);
|
||||
color: var(--bg-dark);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
z-index: 1000;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="prototype-note">
|
||||
🎯 HTML 原型 - 登入頁面
|
||||
</div>
|
||||
|
||||
<div class="auth-layout">
|
||||
<div class="auth-pattern"></div>
|
||||
|
||||
<div class="auth-container">
|
||||
<!-- 左側品牌展示 -->
|
||||
<div class="auth-branding">
|
||||
<div class="brand-logo">🎭</div>
|
||||
|
||||
<h1 class="brand-title">歡迎回來</h1>
|
||||
<p class="brand-subtitle">繼續你的語言學習之旅</p>
|
||||
|
||||
<div class="welcome-back">
|
||||
<h3>上次學習進度</h3>
|
||||
<div class="user-stats">
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">85%</div>
|
||||
<div class="stat-label">完成度</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">42</div>
|
||||
<div class="stat-label">連續天數</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">Level 12</div>
|
||||
<div class="stat-label">當前等級</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右側登入表單 -->
|
||||
<div class="auth-form-container">
|
||||
<form class="login-form" id="loginForm">
|
||||
<div class="form-header">
|
||||
<h2 class="form-title">登入帳戶</h2>
|
||||
<p class="form-subtitle">使用你的帳戶繼續學習</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="email">電子郵件</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
class="form-input"
|
||||
placeholder="請輸入電子郵件地址"
|
||||
value="demo@dramaling.com"
|
||||
required
|
||||
/>
|
||||
<div class="form-error" id="emailError"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="password">密碼</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
class="form-input"
|
||||
placeholder="請輸入密碼"
|
||||
value="demo123456"
|
||||
required
|
||||
/>
|
||||
<div class="form-error" id="passwordError"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-options">
|
||||
<label class="remember-me">
|
||||
<input type="checkbox" id="rememberMe" name="rememberMe" checked>
|
||||
記住我
|
||||
</label>
|
||||
<a href="#" class="forgot-password">忘記密碼?</a>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="submit-btn" id="submitBtn">
|
||||
登入
|
||||
</button>
|
||||
|
||||
<div class="divider">
|
||||
<span>或</span>
|
||||
</div>
|
||||
|
||||
<button type="button" class="google-btn">
|
||||
<span>G</span>
|
||||
使用 Google 登入
|
||||
</button>
|
||||
|
||||
<div class="form-footer">
|
||||
還沒有帳戶? <a href="register.html">立即註冊</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 原型互動功能
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('loginForm');
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
|
||||
// 表單提交
|
||||
form.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const email = document.getElementById('email').value;
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
// 模擬登入過程
|
||||
submitBtn.textContent = '登入中...';
|
||||
submitBtn.disabled = true;
|
||||
|
||||
setTimeout(() => {
|
||||
alert(`🎉 登入成功!\n歡迎回來:${email}\n即將跳轉到學習儀表板...`);
|
||||
submitBtn.textContent = '登入';
|
||||
submitBtn.disabled = false;
|
||||
|
||||
// 在實際應用中,這裡會跳轉到儀表板
|
||||
// window.location.href = 'dashboard.html';
|
||||
}, 1500);
|
||||
});
|
||||
|
||||
// Google 登入
|
||||
document.querySelector('.google-btn').addEventListener('click', function() {
|
||||
alert('🔗 原型演示:Google 登入功能將在實際開發中實現');
|
||||
});
|
||||
|
||||
// 忘記密碼
|
||||
document.querySelector('.forgot-password').addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
alert('📧 原型演示:重設密碼郵件已發送到您的信箱');
|
||||
});
|
||||
|
||||
console.log('🔐 登入頁面原型已載入完成');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -1,587 +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>
|
||||
<link rel="stylesheet" href="../assets/style.css">
|
||||
<style>
|
||||
.auth-layout {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
background: linear-gradient(135deg,
|
||||
var(--primary-teal) 0%,
|
||||
var(--secondary-purple) 50%,
|
||||
var(--accent-violet) 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.auth-layout::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(44, 62, 80, 0.3);
|
||||
}
|
||||
|
||||
.auth-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;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0px) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-20px) rotate(2deg);
|
||||
}
|
||||
}
|
||||
|
||||
.auth-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.auth-branding {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: var(--space-8);
|
||||
color: white;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--text-4xl);
|
||||
margin-bottom: var(--space-6);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: var(--text-4xl);
|
||||
font-weight: 900;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 300;
|
||||
margin-bottom: var(--space-6);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.brand-features {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.feature-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.auth-form-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-8);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.register-form {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
color: var(--bg-dark);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-8);
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.form-header {
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--bg-dark);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.form-subtitle {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--bg-dark);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.form-label .required {
|
||||
color: var(--error-red);
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border: 1px solid #D1D5DB;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-base);
|
||||
color: var(--bg-dark);
|
||||
background: white;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-teal);
|
||||
box-shadow: 0 0 0 3px rgba(0, 229, 204, 0.1);
|
||||
}
|
||||
|
||||
.form-input.error {
|
||||
border-color: var(--error-red);
|
||||
}
|
||||
|
||||
.form-error {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--error-red);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.password-strength {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.strength-bar {
|
||||
height: 4px;
|
||||
background: #f0f0f0;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.strength-fill {
|
||||
height: 100%;
|
||||
transition: all 0.3s ease;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.strength-weak { background: var(--error-red); }
|
||||
.strength-medium { background: var(--warning-yellow); }
|
||||
.strength-strong { background: var(--success-green); }
|
||||
|
||||
.strength-text {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.5;
|
||||
color: var(--bg-dark);
|
||||
}
|
||||
|
||||
.checkbox-label a {
|
||||
color: var(--primary-teal);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.checkbox-label a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
padding: var(--space-4) var(--space-6);
|
||||
background: var(--primary-teal);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
background: var(--primary-teal-light);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 25px rgba(0, 229, 204, 0.3);
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.divider {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
margin: var(--space-6) 0;
|
||||
}
|
||||
|
||||
.divider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.divider span {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 0 var(--space-4);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.google-btn {
|
||||
width: 100%;
|
||||
padding: var(--space-4) var(--space-6);
|
||||
background: white;
|
||||
color: var(--bg-dark);
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
margin-bottom: var(--space-6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.google-btn:hover {
|
||||
background: #f9fafb;
|
||||
border-color: var(--primary-teal);
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
text-align: center;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.form-footer a {
|
||||
color: var(--primary-teal);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 響應式設計 */
|
||||
@media (max-width: 768px) {
|
||||
.auth-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.auth-branding {
|
||||
padding: var(--space-6) var(--space-4);
|
||||
}
|
||||
|
||||
.auth-form-container {
|
||||
padding: var(--space-6) var(--space-4);
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: var(--text-3xl);
|
||||
}
|
||||
}
|
||||
|
||||
/* 原型專用標記 */
|
||||
.prototype-note {
|
||||
position: fixed;
|
||||
top: var(--space-4);
|
||||
right: var(--space-4);
|
||||
background: var(--warning-yellow);
|
||||
color: var(--bg-dark);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
z-index: 1000;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="prototype-note">
|
||||
🎯 HTML 原型 - 註冊頁面
|
||||
</div>
|
||||
|
||||
<div class="auth-layout">
|
||||
<div class="auth-pattern"></div>
|
||||
|
||||
<div class="auth-container">
|
||||
<!-- 左側品牌展示 -->
|
||||
<div class="auth-branding">
|
||||
<div class="brand-logo">🎭</div>
|
||||
|
||||
<h1 class="brand-title">Drama Ling</h1>
|
||||
<p class="brand-subtitle">戲劇式語言學習平台</p>
|
||||
|
||||
<div class="brand-features">
|
||||
<div class="feature-item">
|
||||
<div class="feature-icon">🎯</div>
|
||||
<span>AI 驅動的個人化學習</span>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<div class="feature-icon">🗣️</div>
|
||||
<span>真實對話情境模擬</span>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<div class="feature-icon">🏆</div>
|
||||
<span>遊戲化進度追蹤</span>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<div class="feature-icon">📱</div>
|
||||
<span>隨時隨地學習</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右側註冊表單 -->
|
||||
<div class="auth-form-container">
|
||||
<form class="register-form" id="registerForm">
|
||||
<div class="form-header">
|
||||
<h2 class="form-title">加入 Drama Ling</h2>
|
||||
<p class="form-subtitle">開始你的語言學習之旅</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="username">
|
||||
用戶名稱 <span class="required">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
class="form-input"
|
||||
placeholder="請輸入用戶名稱"
|
||||
required
|
||||
/>
|
||||
<div class="form-error" id="usernameError"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="email">
|
||||
電子郵件 <span class="required">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
class="form-input"
|
||||
placeholder="請輸入電子郵件地址"
|
||||
required
|
||||
/>
|
||||
<div class="form-error" id="emailError"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="password">
|
||||
密碼 <span class="required">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
class="form-input"
|
||||
placeholder="請輸入密碼(至少 8 個字元)"
|
||||
required
|
||||
/>
|
||||
<div class="password-strength">
|
||||
<div class="strength-bar">
|
||||
<div class="strength-fill strength-weak" id="strengthFill" style="width: 0%"></div>
|
||||
</div>
|
||||
<div class="strength-text" id="strengthText">請輸入密碼</div>
|
||||
</div>
|
||||
<div class="form-error" id="passwordError"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="confirmPassword">
|
||||
確認密碼 <span class="required">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
class="form-input"
|
||||
placeholder="請再次輸入密碼"
|
||||
required
|
||||
/>
|
||||
<div class="form-error" id="confirmPasswordError"></div>
|
||||
</div>
|
||||
|
||||
<div class="checkbox-group">
|
||||
<input type="checkbox" id="agreeTerms" name="agreeTerms" class="checkbox" required>
|
||||
<label for="agreeTerms" class="checkbox-label">
|
||||
我同意 <a href="#" target="_blank">使用條款</a> 和 <a href="#" target="_blank">隱私政策</a>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="submit-btn" id="submitBtn" disabled>
|
||||
註冊帳戶
|
||||
</button>
|
||||
|
||||
<div class="divider">
|
||||
<span>或</span>
|
||||
</div>
|
||||
|
||||
<button type="button" class="google-btn">
|
||||
<span>G</span>
|
||||
使用 Google 註冊
|
||||
</button>
|
||||
|
||||
<div class="form-footer">
|
||||
已經有帳戶了? <a href="login.html">立即登入</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 原型互動功能
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('registerForm');
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const passwordInput = document.getElementById('password');
|
||||
const confirmPasswordInput = document.getElementById('confirmPassword');
|
||||
const agreeTermsCheckbox = document.getElementById('agreeTerms');
|
||||
|
||||
// 密碼強度檢查
|
||||
passwordInput.addEventListener('input', function() {
|
||||
const password = this.value;
|
||||
const strengthFill = document.getElementById('strengthFill');
|
||||
const strengthText = document.getElementById('strengthText');
|
||||
|
||||
let score = 0;
|
||||
if (password.length >= 8) score += 25;
|
||||
if (password.match(/[a-z]/)) score += 25;
|
||||
if (password.match(/[A-Z]/)) score += 25;
|
||||
if (password.match(/[0-9]/)) score += 15;
|
||||
if (password.match(/[^a-zA-Z0-9]/)) score += 10;
|
||||
|
||||
strengthFill.style.width = score + '%';
|
||||
|
||||
if (score < 50) {
|
||||
strengthFill.className = 'strength-fill strength-weak';
|
||||
strengthText.textContent = '密碼強度:弱';
|
||||
} else if (score < 80) {
|
||||
strengthFill.className = 'strength-fill strength-medium';
|
||||
strengthText.textContent = '密碼強度:中等';
|
||||
} else {
|
||||
strengthFill.className = 'strength-fill strength-strong';
|
||||
strengthText.textContent = '密碼強度:強';
|
||||
}
|
||||
|
||||
validateForm();
|
||||
});
|
||||
|
||||
// 確認密碼檢查
|
||||
confirmPasswordInput.addEventListener('input', function() {
|
||||
const confirmPasswordError = document.getElementById('confirmPasswordError');
|
||||
if (this.value && this.value !== passwordInput.value) {
|
||||
confirmPasswordError.textContent = '密碼不匹配';
|
||||
this.classList.add('error');
|
||||
} else {
|
||||
confirmPasswordError.textContent = '';
|
||||
this.classList.remove('error');
|
||||
}
|
||||
validateForm();
|
||||
});
|
||||
|
||||
// 表單驗證
|
||||
function validateForm() {
|
||||
const isValid = form.checkValidity() &&
|
||||
passwordInput.value === confirmPasswordInput.value &&
|
||||
agreeTermsCheckbox.checked;
|
||||
|
||||
submitBtn.disabled = !isValid;
|
||||
}
|
||||
|
||||
// 監聽所有輸入變化
|
||||
form.addEventListener('input', validateForm);
|
||||
|
||||
// 表單提交
|
||||
form.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
alert('🎉 原型演示:註冊功能將在實際開發中實現');
|
||||
});
|
||||
|
||||
console.log('📝 註冊頁面原型已載入完成');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,888 +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>
|
||||
<link rel="stylesheet" href="../assets/style.css">
|
||||
<style>
|
||||
.result-layout {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.result-container {
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius-2xl);
|
||||
padding: var(--space-8);
|
||||
box-shadow: var(--shadow-2xl);
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 結果標題 */
|
||||
.result-header {
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
.result-status {
|
||||
font-size: var(--text-6xl);
|
||||
margin-bottom: var(--space-4);
|
||||
animation: celebrationBounce 1s ease-out;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
font-size: var(--text-3xl);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.result-subtitle {
|
||||
font-size: var(--text-lg);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.script-info {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-4);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.script-name {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 600;
|
||||
color: var(--primary-teal);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.completion-time {
|
||||
font-size: var(--text-base);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 星級評分 */
|
||||
.star-rating {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-8);
|
||||
font-size: var(--text-4xl);
|
||||
}
|
||||
|
||||
.star {
|
||||
color: var(--divider);
|
||||
transition: all 0.3s ease;
|
||||
animation-delay: var(--delay, 0s);
|
||||
}
|
||||
|
||||
.star.earned {
|
||||
color: var(--warning-orange);
|
||||
animation: starEarn 0.6s ease-out;
|
||||
}
|
||||
|
||||
/* 詳細評分 */
|
||||
.detailed-scores {
|
||||
display: grid;
|
||||
gap: var(--space-6);
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
.score-item {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
border-left: 4px solid var(--primary-teal);
|
||||
}
|
||||
|
||||
.score-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.score-label {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.score-value {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.score-value.excellent {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: var(--success-green);
|
||||
}
|
||||
|
||||
.score-value.good {
|
||||
background: rgba(251, 146, 60, 0.1);
|
||||
color: var(--warning-orange);
|
||||
}
|
||||
|
||||
.score-value.needs-improvement {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--error-red);
|
||||
}
|
||||
|
||||
.score-bar {
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-full);
|
||||
height: 8px;
|
||||
overflow: hidden;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.score-fill {
|
||||
height: 100%;
|
||||
border-radius: var(--radius-full);
|
||||
transition: width 2s ease-out;
|
||||
background: linear-gradient(90deg, var(--primary-teal), var(--success-green));
|
||||
}
|
||||
|
||||
.score-feedback {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.score-feedback.has-errors {
|
||||
color: var(--error-red);
|
||||
}
|
||||
|
||||
/* 任務和詞彙完成情況 */
|
||||
.completion-summary {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-6);
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
.completion-item {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.completion-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.completion-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.completion-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status-icon.completed {
|
||||
background: var(--success-green);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-icon.missed {
|
||||
background: var(--error-red);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 錯誤訂正選項 */
|
||||
.correction-section {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--divider);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-6);
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
.correction-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.correction-stats {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: var(--space-6);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.correction-stat {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.correction-number {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--error-red);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.correction-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.correction-actions {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.correction-btn {
|
||||
padding: var(--space-3) var(--space-6);
|
||||
border-radius: var(--radius-lg);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border: none;
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.correction-btn.primary {
|
||||
background: var(--primary-teal);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.correction-btn.primary:hover {
|
||||
background: var(--primary-teal-dark);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.correction-btn.secondary {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
border: 2px solid var(--divider);
|
||||
}
|
||||
|
||||
.correction-btn.secondary:hover {
|
||||
border-color: var(--primary-teal);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* 獎勵區域 */
|
||||
.rewards-section {
|
||||
background: linear-gradient(135deg, rgba(0, 229, 204, 0.1), rgba(78, 205, 196, 0.05));
|
||||
border: 1px solid rgba(0, 229, 204, 0.2);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-6);
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
.rewards-title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 600;
|
||||
color: var(--primary-teal);
|
||||
margin-bottom: var(--space-5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.rewards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.reward-item {
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-4);
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow-sm);
|
||||
animation: rewardPop 0.6s ease-out;
|
||||
animation-delay: var(--reward-delay, 0s);
|
||||
}
|
||||
|
||||
.reward-icon {
|
||||
font-size: var(--text-3xl);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.reward-amount {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
color: var(--primary-teal);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.reward-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.claim-reward-btn {
|
||||
background: var(--success-green);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-4) var(--space-8);
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
animation: claimButtonPulse 2s infinite;
|
||||
}
|
||||
|
||||
.claim-reward-btn:hover {
|
||||
background: #059669;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
/* 導航按鈕 */
|
||||
.navigation-actions {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
padding: var(--space-3) var(--space-6);
|
||||
border-radius: var(--radius-lg);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border: none;
|
||||
font-size: var(--text-base);
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.nav-btn.primary {
|
||||
background: var(--primary-teal);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-btn.primary:hover {
|
||||
background: var(--primary-teal-dark);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.nav-btn.secondary {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 2px solid var(--divider);
|
||||
}
|
||||
|
||||
.nav-btn.secondary:hover {
|
||||
border-color: var(--primary-teal);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* 動畫效果 */
|
||||
@keyframes celebrationBounce {
|
||||
0% { transform: scale(0.5); opacity: 0; }
|
||||
50% { transform: scale(1.2); opacity: 0.8; }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes starEarn {
|
||||
0% { transform: scale(0.5); opacity: 0; }
|
||||
50% { transform: scale(1.3); }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes rewardPop {
|
||||
0% { transform: translateY(20px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes claimButtonPulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); }
|
||||
}
|
||||
|
||||
/* 響應式設計 */
|
||||
@media (max-width: 768px) {
|
||||
.result-container {
|
||||
padding: var(--space-6);
|
||||
margin: var(--space-4);
|
||||
}
|
||||
|
||||
.completion-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.correction-stats {
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.rewards-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.navigation-actions, .correction-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.prototype-note {
|
||||
position: fixed;
|
||||
top: var(--space-4);
|
||||
right: var(--space-4);
|
||||
background: var(--success-green);
|
||||
color: var(--bg-dark);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
z-index: 1000;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.error-section {
|
||||
background: rgba(239, 68, 68, 0.05);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.error-title {
|
||||
color: var(--error-red);
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--space-3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.error-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error-item {
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3);
|
||||
margin-bottom: var(--space-2);
|
||||
border-left: 3px solid var(--error-red);
|
||||
}
|
||||
|
||||
.error-original {
|
||||
color: var(--error-red);
|
||||
font-style: italic;
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.error-suggestion {
|
||||
color: var(--success-green);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="prototype-note">
|
||||
🏆 HTML 原型 - 劇本結算
|
||||
</div>
|
||||
|
||||
<div class="result-layout">
|
||||
<div class="result-container">
|
||||
<!-- 結果標題 -->
|
||||
<div class="result-header">
|
||||
<div class="result-status" id="resultEmoji">🎉</div>
|
||||
<h1 class="result-title" id="resultTitle">恭喜過關!</h1>
|
||||
<p class="result-subtitle" id="resultSubtitle">你在情境對話中表現出色!</p>
|
||||
|
||||
<div class="script-info">
|
||||
<div class="script-name">📖 午餐吃什麼</div>
|
||||
<div class="completion-time">⏱️ 完成時間:4分23秒 / 5分00秒</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 星級評分 -->
|
||||
<div class="star-rating" id="starRating">
|
||||
<span class="star" data-star="1">⭐</span>
|
||||
<span class="star" data-star="2">⭐</span>
|
||||
<span class="star" data-star="3">⭐</span>
|
||||
</div>
|
||||
|
||||
<!-- 詳細評分 -->
|
||||
<div class="detailed-scores">
|
||||
<div class="score-item">
|
||||
<div class="score-header">
|
||||
<span class="score-label">💬 對話語意合適度</span>
|
||||
<span class="score-value excellent" id="semanticScore">85分</span>
|
||||
</div>
|
||||
<div class="score-bar">
|
||||
<div class="score-fill" style="width: 0%" data-target="85"></div>
|
||||
</div>
|
||||
<div class="score-feedback">
|
||||
回應切合情境,對話流暢自然,能適當表達個人喜好並與對方互動。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="score-item">
|
||||
<div class="score-header">
|
||||
<span class="score-label">📝 語法正確性</span>
|
||||
<span class="score-value needs-improvement" id="grammarScore">2處錯誤</span>
|
||||
</div>
|
||||
<div class="score-bar">
|
||||
<div class="score-fill" style="width: 0%; background: linear-gradient(90deg, var(--error-red), var(--warning-orange));" data-target="70"></div>
|
||||
</div>
|
||||
<div class="score-feedback has-errors">
|
||||
發現2處語法錯誤,建議進行訂正練習以提升語法準確度。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="score-item">
|
||||
<div class="score-header">
|
||||
<span class="score-label">🎵 表達流暢度</span>
|
||||
<span class="score-value good" id="fluencyScore">78分</span>
|
||||
</div>
|
||||
<div class="score-bar">
|
||||
<div class="score-fill" style="width: 0%" data-target="78"></div>
|
||||
</div>
|
||||
<div class="score-feedback">
|
||||
發音清晰,語調自然,偶有停頓但整體表達流暢。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 任務和詞彙完成情況 -->
|
||||
<div class="completion-summary">
|
||||
<div class="completion-item">
|
||||
<div class="completion-title">
|
||||
🎯 劇情任務完成情況
|
||||
</div>
|
||||
<div class="completion-list">
|
||||
<div class="completion-list-item">
|
||||
<div class="status-icon completed">✓</div>
|
||||
<span>說服Jamie吃自己喜歡的食物</span>
|
||||
</div>
|
||||
<div class="completion-list-item">
|
||||
<div class="status-icon completed">✓</div>
|
||||
<span>提出至少2種食物選擇並妥協</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="completion-item">
|
||||
<div class="completion-title">
|
||||
📚 指定詞彙提及情況
|
||||
</div>
|
||||
<div class="completion-list">
|
||||
<div class="completion-list-item">
|
||||
<div class="status-icon completed">✓</div>
|
||||
<span>decide (決定)</span>
|
||||
</div>
|
||||
<div class="completion-list-item">
|
||||
<div class="status-icon missed">✗</div>
|
||||
<span>light (清淡的)</span>
|
||||
</div>
|
||||
<div class="completion-list-item">
|
||||
<div class="status-icon completed">✓</div>
|
||||
<span>how about (⋯⋯怎麼樣?)</span>
|
||||
</div>
|
||||
<div class="completion-list-item">
|
||||
<div class="status-icon completed">✓</div>
|
||||
<span>can't decide (拿不定主意)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 語法錯誤詳情 -->
|
||||
<div class="error-section" id="errorSection">
|
||||
<div class="error-title">
|
||||
❌ 發現的語法錯誤
|
||||
</div>
|
||||
<ul class="error-list">
|
||||
<li class="error-item">
|
||||
<div class="error-original">原句:I think burger is more better than salad.</div>
|
||||
<div class="error-suggestion">建議:I think burgers are better than salad.</div>
|
||||
</li>
|
||||
<li class="error-item">
|
||||
<div class="error-original">原句:How about we goes to that new place?</div>
|
||||
<div class="error-suggestion">建議:How about we go to that new place?</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 錯誤訂正選項 -->
|
||||
<div class="correction-section" id="correctionSection">
|
||||
<h3 class="correction-title">🔧 立即訂正錯誤</h3>
|
||||
<p style="color: var(--text-secondary); margin-bottom: var(--space-4);">
|
||||
訂正錯誤可以提升你的最終評分,並獲得額外獎勵!
|
||||
</p>
|
||||
|
||||
<div class="correction-stats">
|
||||
<div class="correction-stat">
|
||||
<span class="correction-number">2</span>
|
||||
<span class="correction-label">語法錯誤</span>
|
||||
</div>
|
||||
<div class="correction-stat">
|
||||
<span class="correction-number">1</span>
|
||||
<span class="correction-label">表達不順</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="correction-actions">
|
||||
<button class="correction-btn primary" id="correctBtn">
|
||||
✏️ 立即訂正
|
||||
</button>
|
||||
<button class="correction-btn secondary" id="skipCorrectionBtn">
|
||||
⏭️ 跳過訂正
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 獎勵區域 -->
|
||||
<div class="rewards-section" id="rewardsSection" style="display: none;">
|
||||
<div class="rewards-title">
|
||||
🎁 通關獎勵
|
||||
</div>
|
||||
|
||||
<div class="rewards-grid">
|
||||
<div class="reward-item" style="--reward-delay: 0s;">
|
||||
<div class="reward-icon">🪙</div>
|
||||
<div class="reward-amount">+150</div>
|
||||
<div class="reward-label">金幣</div>
|
||||
</div>
|
||||
<div class="reward-item" style="--reward-delay: 0.2s;">
|
||||
<div class="reward-icon">⭐</div>
|
||||
<div class="reward-amount">+85</div>
|
||||
<div class="reward-label">經驗值</div>
|
||||
</div>
|
||||
<div class="reward-item" style="--reward-delay: 0.4s;">
|
||||
<div class="reward-icon">💎</div>
|
||||
<div class="reward-amount">+2</div>
|
||||
<div class="reward-label">寶石</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="claim-reward-btn" id="claimRewardsBtn">
|
||||
🏆 領取獎勵
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 導航按鈕 -->
|
||||
<div class="navigation-actions">
|
||||
<a href="roleplay.html" class="nav-btn secondary">
|
||||
📋 選擇其他劇本
|
||||
</a>
|
||||
<a href="roleplay-room.html" class="nav-btn primary">
|
||||
🔄 重新挑戰
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 模擬結算數據
|
||||
const resultData = {
|
||||
passed: true,
|
||||
stars: 2,
|
||||
scores: {
|
||||
semantic: 85,
|
||||
grammar: 70, // 有錯誤所以分數較低
|
||||
fluency: 78
|
||||
},
|
||||
tasks: {
|
||||
completed: 2,
|
||||
total: 2
|
||||
},
|
||||
vocabulary: {
|
||||
mentioned: 3,
|
||||
total: 4
|
||||
},
|
||||
errors: {
|
||||
grammar: 2,
|
||||
fluency: 1
|
||||
},
|
||||
rewards: {
|
||||
coins: 150,
|
||||
experience: 85,
|
||||
gems: 2
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化結果顯示
|
||||
function initializeResult() {
|
||||
// 設置結果標題
|
||||
if (resultData.passed) {
|
||||
document.getElementById('resultEmoji').textContent = '🎉';
|
||||
document.getElementById('resultTitle').textContent = '恭喜過關!';
|
||||
document.getElementById('resultSubtitle').textContent = '你在情境對話中表現出色!';
|
||||
} else {
|
||||
document.getElementById('resultEmoji').textContent = '😅';
|
||||
document.getElementById('resultTitle').textContent = '挑戰失敗';
|
||||
document.getElementById('resultSubtitle').textContent = '別氣餒,再試一次一定可以的!';
|
||||
}
|
||||
|
||||
// 延遲顯示星星
|
||||
setTimeout(() => {
|
||||
animateStars(resultData.stars);
|
||||
}, 500);
|
||||
|
||||
// 延遲顯示分數條
|
||||
setTimeout(() => {
|
||||
animateScoreBars();
|
||||
}, 1000);
|
||||
|
||||
// 顯示或隱藏訂正區域
|
||||
if (resultData.errors.grammar > 0 || resultData.errors.fluency > 0) {
|
||||
document.getElementById('correctionSection').style.display = 'block';
|
||||
document.getElementById('errorSection').style.display = 'block';
|
||||
} else {
|
||||
document.getElementById('correctionSection').style.display = 'none';
|
||||
document.getElementById('errorSection').style.display = 'none';
|
||||
showRewards();
|
||||
}
|
||||
}
|
||||
|
||||
// 星星動畫
|
||||
function animateStars(earnedStars) {
|
||||
const stars = document.querySelectorAll('.star');
|
||||
stars.forEach((star, index) => {
|
||||
if (index < earnedStars) {
|
||||
setTimeout(() => {
|
||||
star.classList.add('earned');
|
||||
star.style.setProperty('--delay', `${index * 0.2}s`);
|
||||
}, index * 300);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 分數條動畫
|
||||
function animateScoreBars() {
|
||||
const scoreFills = document.querySelectorAll('.score-fill');
|
||||
scoreFills.forEach(fill => {
|
||||
const target = fill.dataset.target;
|
||||
setTimeout(() => {
|
||||
fill.style.width = target + '%';
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
// 顯示獎勵區域
|
||||
function showRewards() {
|
||||
document.getElementById('correctionSection').style.display = 'none';
|
||||
document.getElementById('rewardsSection').style.display = 'block';
|
||||
}
|
||||
|
||||
// 訂正按鈕事件
|
||||
document.getElementById('correctBtn')?.addEventListener('click', function() {
|
||||
// 模擬跳轉到訂正頁面
|
||||
alert('🔧 正在前往錯誤訂正頁面...\n\n將會逐一訂正發現的語法錯誤和表達不順的句子。');
|
||||
|
||||
// 模擬訂正完成後的獎勵提升
|
||||
setTimeout(() => {
|
||||
// 更新獎勵(訂正後獲得額外獎勵)
|
||||
resultData.rewards.coins += 50;
|
||||
resultData.rewards.experience += 25;
|
||||
resultData.rewards.gems += 1;
|
||||
|
||||
// 更新顯示
|
||||
document.querySelector('.reward-item:nth-child(1) .reward-amount').textContent = `+${resultData.rewards.coins}`;
|
||||
document.querySelector('.reward-item:nth-child(2) .reward-amount').textContent = `+${resultData.rewards.experience}`;
|
||||
document.querySelector('.reward-item:nth-child(3) .reward-amount').textContent = `+${resultData.rewards.gems}`;
|
||||
|
||||
showRewards();
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
// 跳過訂正
|
||||
document.getElementById('skipCorrectionBtn')?.addEventListener('click', function() {
|
||||
showRewards();
|
||||
});
|
||||
|
||||
// 領取獎勵
|
||||
document.getElementById('claimRewardsBtn')?.addEventListener('click', function() {
|
||||
this.textContent = '✅ 獎勵已領取';
|
||||
this.style.background = 'var(--success-green)';
|
||||
this.disabled = true;
|
||||
|
||||
// 顯示獲得獎勵的動畫效果
|
||||
document.querySelectorAll('.reward-item').forEach((item, index) => {
|
||||
setTimeout(() => {
|
||||
item.style.transform = 'scale(1.1)';
|
||||
setTimeout(() => {
|
||||
item.style.transform = 'scale(1)';
|
||||
}, 200);
|
||||
}, index * 100);
|
||||
});
|
||||
|
||||
// 更新用戶資源(這裡只是模擬)
|
||||
setTimeout(() => {
|
||||
alert('🎉 獎勵已成功添加到你的賬戶!');
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
// 成就解鎖檢查(示例)
|
||||
function checkAchievements() {
|
||||
const achievements = [];
|
||||
|
||||
if (resultData.scores.semantic >= 80) {
|
||||
achievements.push('🎯 對話達人 - 語意合適度超過80分');
|
||||
}
|
||||
|
||||
if (resultData.stars === 3) {
|
||||
achievements.push('⭐ 完美表現 - 獲得三星評價');
|
||||
}
|
||||
|
||||
if (resultData.tasks.completed === resultData.tasks.total &&
|
||||
resultData.vocabulary.mentioned === resultData.vocabulary.total) {
|
||||
achievements.push('🏆 完美通關 - 完成所有任務和詞彙');
|
||||
}
|
||||
|
||||
if (achievements.length > 0) {
|
||||
setTimeout(() => {
|
||||
alert('🎊 恭喜解鎖成就!\n\n' + achievements.join('\n'));
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化頁面
|
||||
initializeResult();
|
||||
setTimeout(checkAchievements, 3000);
|
||||
|
||||
console.log('🏆 劇本結算原型已載入完成');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue