Compare commits

..

No commits in common. "9345654cc17f782935d4e520fefaf839b89f2e21" and "d31340a05a464741c11d2f58b2787e75a5656242" have entirely different histories.

285 changed files with 185 additions and 52045 deletions

View File

@ -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": []

View File

@ -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 開發團隊

View File

@ -17,24 +17,6 @@
- [ ] UI組件命名規範
- [ ] 部分UI功能重複可能需要合併多個Result相關UI
### 📱 HTML原型缺失項目 (2025-09-09)
#### 🔧 高優先級 - 學習閉環核心功能
- [ ] **語法錯誤訂正頁面** - 完成學習閉環的關鍵頁面,包含進度條和練習按鈕功能
- [ ] **表達不順訂正頁面** - 語音發音訂正界面,與語法訂正配合完成訂正流程
#### 💻 中優先級 - 用戶體驗完善
- [ ] **文字輸入彈窗界面** - 替換現有prompt()的完整彈窗UI設計
- [ ] **頁面導航連接** - 完善結算→訂正→完成的完整用戶流程導航
- [ ] **特殊情況處理** - 中文輸入聽不懂、無聲音提示等錯誤狀態處理
#### 🎨 低優先級 - 細節優化
- [ ] **問題回報視窗** - 5選項回報機制的完整UI實現
- [ ] **UI規格細節** - 語法檢查顏色標準、載入動畫等視覺細節
- [ ] **通關協助頁面** - 寶石消耗協助功能的詳細界面設計
**影響評估**: 高優先級項目直接影響學習效果,中優先級影響用戶體驗流暢度
## 🤖 與 Claude 協作提醒

View File

@ -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
View File

@ -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%完整覆蓋

View File

@ -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`

View File

@ -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/`

View File

@ -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/`

View File

@ -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,
}

View File

@ -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,
);
}
}

View File

@ -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)';
}
}

View File

@ -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('查看結果'),
),
],
),
);
}
}

View File

@ -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 '我明白了。還有什麼我可以為您服務的嗎?';
}
}
}

View File

@ -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,
),
),
],
],
);
}
}

View File

@ -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],
),
),
),
],
),
);
}
}

View File

@ -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,
),
),
),
);
}
}

View File

@ -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,
),
),
),
),
);
},
),
),
],
),
),
),
);
}
}

View File

@ -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,
),
),
],
),
);
}
}

View File

@ -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(),
],
),
);
}
}

View File

@ -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)';
}
}

View File

@ -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),
),
),
],
);
}
}

View File

@ -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'
}
}

View File

@ -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"
}

View File

@ -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']>
}
}

View File

@ -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']
}
}

View File

@ -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>

13619
apps/web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}

View File

@ -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>

View File

@ -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;
}

View File

@ -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

View File

@ -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; }
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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')

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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']
}
})

View File

@ -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'

View File

@ -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
}
})

View File

@ -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']
}
})

View File

@ -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']
}
})

View File

@ -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
}

View File

@ -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 // 排名變化
}

View File

@ -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
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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"
]
}

View File

@ -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
View File

@ -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
;;

View File

@ -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)

View File

@ -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文檔

View File

@ -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端功能規格

View File

@ -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端功能規格

View File

@ -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)

View File

@ -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開發團隊

View File

@ -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規格版本號]

View File

@ -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接口規格

View File

@ -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接口規格

View File

@ -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接口規格

View File

@ -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接口規格

View File

@ -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接口規格

View File

@ -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` - 開發進度追蹤

View File

@ -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. **詞彙學習** - 核心學習模組
### 未來擴展
- 移動端特化版本
- 深色/淺色主題切換
- 多語言版本展示
- 組件庫完整化
---
## 📞 聯絡資訊
如有問題或建議,請:
- 查看具體頁面的註解說明
- 參考設計規格文檔
- 提出改進建議
**記住:原型的目標是確認需求,避免開發階段的大幅修改!** ✨

View File

@ -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);
}

View File

@ -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>

View File

@ -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

View File

@ -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>

View File

@ -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

View File

@ -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>

View File

@ -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