From e05e6f09f2dfd05924bb09b23e3a0adf394bf573 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=84=AD=E6=B2=9B=E8=BB=92?= Date: Wed, 24 Sep 2025 16:23:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AF=A6=E7=8F=BE=E9=80=B2=E9=9A=8E?= =?UTF-8?q?=E6=90=9C=E5=B0=8B=E5=8A=9F=E8=83=BD=E7=9A=84=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E5=89=8D=E5=BE=8C=E7=AB=AF=E6=9E=B6=E6=A7=8B=E9=87=8D=E6=A7=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增完整的前後端架構設計文檔 - 實現 useFlashcardSearch Hook 統一狀態管理 - 重構 FlashcardsPage 使用新架構 - 添加排序和分頁功能 - 實現客戶端 CEFR 等級篩選 - 修復 TypeScript 類型錯誤 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- ADVANCED_SEARCH_PLAN.md | 306 +++++ BACKEND_API_STRATEGY.md | 461 ++++++++ FRONTEND_ARCHITECTURE.md | 620 ++++++++++ frontend/app/flashcards/page.tsx | 1624 +++++++++++--------------- frontend/hooks/useAudio.ts | 3 +- frontend/hooks/useDebounce.ts | 82 ++ frontend/hooks/useFlashcardSearch.ts | 374 ++++++ frontend/lib/services/flashcards.ts | 14 +- 8 files changed, 2552 insertions(+), 932 deletions(-) create mode 100644 ADVANCED_SEARCH_PLAN.md create mode 100644 BACKEND_API_STRATEGY.md create mode 100644 FRONTEND_ARCHITECTURE.md create mode 100644 frontend/hooks/useDebounce.ts create mode 100644 frontend/hooks/useFlashcardSearch.ts diff --git a/ADVANCED_SEARCH_PLAN.md b/ADVANCED_SEARCH_PLAN.md new file mode 100644 index 0000000..264eb54 --- /dev/null +++ b/ADVANCED_SEARCH_PLAN.md @@ -0,0 +1,306 @@ +# 🔍 進階搜尋功能完善計劃 + +## 📋 現狀評估 + +### ✅ 已完成功能 +- [x] 基本文字搜尋(詞彙、翻譯、定義) +- [x] CEFR等級篩選 (A1-C2) +- [x] 詞性篩選 (noun, verb, adjective, etc.) +- [x] 掌握程度篩選 (高/中/低) +- [x] 收藏狀態篩選 +- [x] 快速篩選按鈕 +- [x] 搜尋結果高亮 +- [x] 防抖搜尋 (200ms) +- [x] ESC 鍵清除篩選 +- [x] 焦點管理優化 + +### 🚫 缺失的核心功能 + +#### 1. **排序功能** +- **缺失**: 沒有詞卡排序選項 +- **需要**: 按創建時間、掌握度、字母順序、CEFR等級排序 +- **影響**: 用戶無法按需要的順序瀏覽詞卡 + +#### 2. **分頁功能** +- **缺失**: 沒有分頁機制 +- **問題**: 大量詞卡時載入慢、滾動困難 +- **需要**: 分頁導航、每頁數量選擇 + +#### 3. **進階搜尋條件** +- **缺失**: 創建日期範圍篩選 +- **缺失**: 複習次數篩選 +- **缺失**: 例句內容搜尋 + +#### 4. **搜尋歷史記錄** +- **缺失**: 搜尋記錄保存 +- **缺失**: 常用篩選組合快捷鍵 + +#### 5. **批量操作** +- **缺失**: 批量選擇詞卡 +- **缺失**: 批量收藏/取消收藏 +- **缺失**: 批量刪除 + +#### 6. **搜尋結果優化** +- **缺失**: 搜尋結果相關性排序 +- **缺失**: 模糊搜尋支援 +- **缺失**: 搜尋建議 + +--- + +## 🎯 四階段改善計劃 + +### 第一階段:排序與分頁 (優先) +> **預計時間**: 3-5天 +> **難度**: ⭐⭐ +> **價值**: ⭐⭐⭐⭐⭐ + +#### 1.1 新增排序功能 +- [ ] 添加排序下拉選單組件 + - 創建時間 (最新/最舊) + - 掌握度 (高到低/低到高) + - 字母順序 (A-Z/Z-A) + - CEFR等級 (A1-C2/C2-A1) + - 複習次數 (多到少/少到多) +- [ ] 升序/降序切換按鈕 +- [ ] 更新前端狀態管理 +- [ ] 更新 API 參數支援排序 +- [ ] 後端實現排序邏輯 + +#### 1.2 實現分頁機制 +- [ ] 分頁導航組件設計 +- [ ] 每頁數量選擇 (10/20/50/100) +- [ ] 頁碼跳轉功能 +- [ ] 總數統計顯示 +- [ ] URL 參數同步 (支援書籤分享) +- [ ] 更新後端 API 支援 `page`, `limit`, `offset` 參數 +- [ ] 無限滾動模式 (可選) + +--- + +### 第二階段:進階篩選條件 +> **預計時間**: 4-6天 +> **難度**: ⭐⭐⭐ +> **價值**: ⭐⭐⭐⭐ + +#### 2.1 時間範圍篩選 +- [ ] 創建日期範圍選擇器 +- [ ] 最後複習時間篩選 +- [ ] 預設快捷選項 + - 今天 + - 昨天 + - 本週 + - 本月 + - 上個月 + - 自定義範圍 +- [ ] 日曆組件整合 + +#### 2.2 複習統計篩選 +- [ ] 複習次數範圍篩選 (滑桿組件) +- [ ] 正確率篩選 (0-100%) +- [ ] 學習狀態篩選 + - 從未複習 + - 學習中 + - 已掌握 + - 需要複習 +- [ ] 連續答對次數篩選 + +#### 2.3 內容深度搜尋 +- [ ] 例句內容搜尋 +- [ ] 定義內容搜尋 +- [ ] 標籤搜尋 (如果有標籤系統) +- [ ] 多關鍵字組合搜尋 (AND/OR) + +--- + +### 第三階段:用戶體驗優化 +> **預計時間**: 5-7天 +> **難度**: ⭐⭐⭐⭐ +> **價值**: ⭐⭐⭐⭐ + +#### 3.1 搜尋歷史與快捷 +- [ ] localStorage 保存搜尋記錄 +- [ ] 搜尋歷史下拉選單 +- [ ] 常用篩選組合儲存 +- [ ] 自定義篩選預設 +- [ ] 一鍵重置到個人偏好 +- [ ] 搜尋記錄管理 (清除、固定) + +#### 3.2 批量操作系統 +- [ ] 多選 checkbox 界面 +- [ ] 全選/反選/部分選功能 +- [ ] 批量操作工具列 + - 批量收藏/取消收藏 + - 批量刪除 + - 批量標記為已掌握 + - 批量移動到複習列表 +- [ ] 批量操作確認對話框 +- [ ] 操作結果通知 + +#### 3.3 界面優化 +- [ ] 響應式設計改善 +- [ ] 搜尋結果載入骨架 +- [ ] 空狀態優化設計 +- [ ] 篩選條件摺疊/展開動畫 +- [ ] 搜尋結果數量動畫 + +--- + +### 第四階段:搜尋智能化 +> **預計時間**: 7-10天 +> **難度**: ⭐⭐⭐⭐⭐ +> **價值**: ⭐⭐⭐ + +#### 4.1 智能搜尋算法 +- [ ] 模糊搜尋實現 (Fuzzy Search) +- [ ] 相關性排序算法 +- [ ] 詞根匹配 (英語詞根系統) +- [ ] 同義詞搜尋 +- [ ] 拼寫錯誤容錯 + +#### 4.2 搜尋建議系統 +- [ ] 自動完成功能 +- [ ] 搜尋建議下拉 +- [ ] 相關詞彙推薦 +- [ ] 搜尋熱詞統計 +- [ ] 個性化建議 + +#### 4.3 效能優化 +- [ ] 虛擬滾動支援大量數據 +- [ ] 搜尋結果快取策略 +- [ ] 防抖優化進階版 +- [ ] 背景預加載 +- [ ] CDN 快取優化 + +--- + +## 🛠️ 技術實現細節 + +### 前端技術棧 +- **UI組件**: 自定義組件 + Tailwind CSS +- **狀態管理**: React useState + useEffect +- **快取策略**: localStorage + sessionStorage +- **虛擬滾動**: react-window (如需要) +- **日期選擇**: react-datepicker +- **模糊搜尋**: fuse.js + +### 後端 API 擴展 +```typescript +// 新增 API 參數 +interface GetFlashcardsParams { + // 現有參數 + search?: string; + favoritesOnly?: boolean; + cefrLevel?: string; + partOfSpeech?: string; + masteryLevel?: string; + + // 新增參數 + page?: number; // 頁碼 + limit?: number; // 每頁數量 + sortBy?: string; // 排序字段 + sortOrder?: 'asc' | 'desc'; // 排序方向 + dateFrom?: string; // 創建時間起始 + dateTo?: string; // 創建時間結束 + reviewCountMin?: number; // 最少複習次數 + reviewCountMax?: number; // 最多複習次數 + accuracyMin?: number; // 最低正確率 + accuracyMax?: number; // 最高正確率 +} +``` + +### 資料庫查詢優化 +- 添加相關索引 (created_at, mastery_level, review_count) +- 分頁查詢優化 +- 全文搜尋索引 (如果支援) + +--- + +## 📊 成功指標 + +### 使用者體驗指標 +- [ ] 搜尋回應時間 < 300ms +- [ ] 分頁載入時間 < 200ms +- [ ] 用戶搜尋成功率 > 90% +- [ ] 平均搜尋步驟 < 3步 + +### 功能完成度指標 +- [ ] 階段一功能 100% 完成 +- [ ] 階段二功能 100% 完成 +- [ ] 階段三功能 80% 完成 +- [ ] 階段四功能 60% 完成 + +### 代碼品質指標 +- [ ] TypeScript 類型覆蓋率 > 95% +- [ ] 單元測試覆蓋率 > 80% +- [ ] ESLint 規則 100% 通過 +- [ ] 效能測試通過 + +--- + +## 📅 時程規劃 + +| 階段 | 功能 | 預計時間 | 優先級 | 負責人 | +|------|------|----------|--------|--------| +| 1 | 排序功能 | 2-3天 | P0 | 開發者 | +| 1 | 分頁機制 | 2-3天 | P0 | 開發者 | +| 2 | 時間篩選 | 2-3天 | P1 | 開發者 | +| 2 | 複習統計篩選 | 2-3天 | P1 | 開發者 | +| 3 | 搜尋歷史 | 3-4天 | P2 | 開發者 | +| 3 | 批量操作 | 2-3天 | P2 | 開發者 | +| 4 | 智能搜尋 | 5-7天 | P3 | 開發者 | +| 4 | 效能優化 | 2-3天 | P3 | 開發者 | + +**總計預估**: 20-29 天 + +--- + +## 🔄 迭代策略 + +### MVP (最小可行產品) +**目標**: 第一階段功能 +- 基本排序 (創建時間、掌握度) +- 簡單分頁 (固定每頁 20 個) + +### V1.0 +**目標**: 第一、二階段功能 +- 完整排序選項 +- 靈活分頁配置 +- 時間範圍篩選 +- 複習統計篩選 + +### V2.0 +**目標**: 第三階段功能 +- 搜尋歷史 +- 批量操作 +- UI/UX 優化 + +### V3.0 +**目標**: 第四階段功能 +- 智能搜尋 +- 效能優化 +- 進階分析 + +--- + +## 📝 備注 + +### 技術債務 +- 現有搜尋邏輯需重構以支援新功能 +- API 回應格式可能需要調整 +- 前端狀態管理複雜度會增加 + +### 風險評估 +- **高風險**: 大量數據時的效能問題 +- **中風險**: 複雜篩選條件的 UI 設計 +- **低風險**: 基本排序和分頁功能 + +### 測試策略 +- 單元測試:搜尋邏輯、篩選函數 +- 整合測試:API 調用、狀態管理 +- E2E 測試:用戶搜尋流程 +- 效能測試:大量數據場景 + +--- + +*最後更新: 2025-09-24* +*版本: 1.0* \ No newline at end of file diff --git a/BACKEND_API_STRATEGY.md b/BACKEND_API_STRATEGY.md new file mode 100644 index 0000000..c879521 --- /dev/null +++ b/BACKEND_API_STRATEGY.md @@ -0,0 +1,461 @@ +# 🚀 後端API完整策略設計 + +## 📊 現狀分析 + +### ✅ 現有功能 +- 基本詞卡CRUD操作 +- 用戶收藏功能 +- 基本資料結構完整 + +### ❌ 缺失功能 +- **分頁功能** - 不支援page/limit參數 +- **篩選功能** - difficultyLevel、partOfSpeech等參數無效 +- **排序功能** - 不支援sortBy/sortOrder參數 +- **搜尋功能** - 基本搜尋可能不完整 +- **效能索引** - 缺少資料庫索引優化 + +--- + +## 🎯 完整API設計 + +### 核心端點:GET /api/flashcards + +#### 請求參數 (Query Parameters) +```typescript +interface FlashcardQueryParams { + // 搜尋和篩選 + search?: string; // 全文搜尋 (詞彙、翻譯、定義) + difficultyLevel?: string; // CEFR等級 (A1, A2, B1, B2, C1, C2) + partOfSpeech?: string; // 詞性 (noun, verb, adjective, etc.) + masteryLevel?: string; // 掌握程度 (low: <60%, medium: 60-79%, high: 80%+) + favoritesOnly?: boolean; // 僅收藏詞卡 + + // 時間範圍篩選 + createdAfter?: string; // 創建時間起始 (ISO 8601) + createdBefore?: string; // 創建時間結束 (ISO 8601) + reviewedAfter?: string; // 最後複習時間起始 + reviewedBefore?: string; // 最後複習時間結束 + + // 複習統計篩選 + reviewCountMin?: number; // 最少複習次數 + reviewCountMax?: number; // 最多複習次數 + + // 排序 + sortBy?: string; // 排序字段 (createdAt, word, masteryLevel, difficultyLevel, timesReviewed) + sortOrder?: 'asc' | 'desc'; // 排序方向 + + // 分頁 + page?: number; // 頁碼 (從1開始) + limit?: number; // 每頁數量 (預設20,最大100) + + // 其他 + includeMeta?: boolean; // 是否包含元數據 (預設true) +} +``` + +#### 標準回應格式 +```typescript +interface FlashcardQueryResponse { + success: boolean; + data: { + flashcards: Flashcard[]; + pagination: { + current_page: number; + total_pages: number; + total_count: number; + page_size: number; + has_next: boolean; + has_prev: boolean; + }; + filters_applied: { + search?: string; + difficulty_level?: string; + part_of_speech?: string; + mastery_level?: string; + favorites_only?: boolean; + // ... 其他應用的篩選 + }; + meta?: { + query_time_ms: number; + cache_hit: boolean; + }; + }; + error?: string; +} +``` + +--- + +## 🏗️ 後端實作策略 + +### 階段一:基礎功能修復 (必需) + +#### 1.1 分頁功能實作 +```python +def get_flashcards(): + # 分頁參數 + page = int(request.args.get('page', 1)) + limit = min(int(request.args.get('limit', 20)), 100) # 最大100 + offset = (page - 1) * limit + + # 構建基本查詢 + query = Flashcard.query.filter_by(user_id=current_user.id) + + # 應用篩選 (後續實作) + query = apply_filters(query, request.args) + + # 應用排序 (後續實作) + query = apply_sorting(query, request.args) + + # 計算總數 (在分頁之前) + total_count = query.count() + + # 執行分頁查詢 + flashcards = query.offset(offset).limit(limit).all() + + # 計算分頁資訊 + total_pages = math.ceil(total_count / limit) + + return jsonify({ + 'success': True, + 'data': { + 'flashcards': [card.to_dict() for card in flashcards], + 'pagination': { + 'current_page': page, + 'total_pages': total_pages, + 'total_count': total_count, + 'page_size': limit, + 'has_next': page < total_pages, + 'has_prev': page > 1 + } + } + }) +``` + +#### 1.2 篩選功能實作 +```python +def apply_filters(query, args): + """應用所有篩選條件""" + + # 全文搜尋 + search = args.get('search') + if search: + search_pattern = f"%{search}%" + query = query.filter( + db.or_( + Flashcard.word.ilike(search_pattern), + Flashcard.translation.ilike(search_pattern), + Flashcard.definition.ilike(search_pattern), + Flashcard.example.ilike(search_pattern) + ) + ) + + # CEFR等級篩選 + difficulty_level = args.get('difficultyLevel') + if difficulty_level: + query = query.filter(Flashcard.difficulty_level == difficulty_level) + + # 詞性篩選 + part_of_speech = args.get('partOfSpeech') + if part_of_speech: + query = query.filter(Flashcard.part_of_speech == part_of_speech) + + # 掌握程度篩選 + mastery_level = args.get('masteryLevel') + if mastery_level: + if mastery_level == 'high': + query = query.filter(Flashcard.mastery_level >= 80) + elif mastery_level == 'medium': + query = query.filter( + Flashcard.mastery_level >= 60, + Flashcard.mastery_level < 80 + ) + elif mastery_level == 'low': + query = query.filter(Flashcard.mastery_level < 60) + + # 收藏篩選 + favorites_only = args.get('favoritesOnly', 'false').lower() == 'true' + if favorites_only: + query = query.filter(Flashcard.is_favorite == True) + + # 時間範圍篩選 + created_after = args.get('createdAfter') + if created_after: + query = query.filter(Flashcard.created_at >= created_after) + + created_before = args.get('createdBefore') + if created_before: + query = query.filter(Flashcard.created_at <= created_before) + + # 複習次數篩選 + review_count_min = args.get('reviewCountMin') + if review_count_min: + query = query.filter(Flashcard.times_reviewed >= int(review_count_min)) + + review_count_max = args.get('reviewCountMax') + if review_count_max: + query = query.filter(Flashcard.times_reviewed <= int(review_count_max)) + + return query +``` + +#### 1.3 排序功能實作 +```python +def apply_sorting(query, args): + """應用排序邏輯""" + sort_by = args.get('sortBy', 'createdAt') + sort_order = args.get('sortOrder', 'desc') + + # 排序字段映射 + sort_fields = { + 'createdAt': Flashcard.created_at, + 'word': Flashcard.word, + 'masteryLevel': Flashcard.mastery_level, + 'difficultyLevel': Flashcard.difficulty_level, + 'timesReviewed': Flashcard.times_reviewed + } + + if sort_by not in sort_fields: + sort_by = 'createdAt' # 預設排序 + + sort_field = sort_fields[sort_by] + + if sort_order.lower() == 'desc': + query = query.order_by(sort_field.desc()) + else: + query = query.order_by(sort_field.asc()) + + # CEFR等級特殊排序 + if sort_by == 'difficultyLevel': + # 需要自定義排序邏輯 A1 < A2 < B1 < B2 < C1 < C2 + level_order = case( + (Flashcard.difficulty_level == 'A1', 1), + (Flashcard.difficulty_level == 'A2', 2), + (Flashcard.difficulty_level == 'B1', 3), + (Flashcard.difficulty_level == 'B2', 4), + (Flashcard.difficulty_level == 'C1', 5), + (Flashcard.difficulty_level == 'C2', 6), + else_=7 + ) + + if sort_order.lower() == 'desc': + query = query.order_by(level_order.desc()) + else: + query = query.order_by(level_order.asc()) + + return query +``` + +### 階段二:資料庫優化 + +#### 2.1 索引建立 +```sql +-- 基本篩選索引 +CREATE INDEX idx_flashcards_user_difficulty ON flashcards(user_id, difficulty_level); +CREATE INDEX idx_flashcards_user_part_of_speech ON flashcards(user_id, part_of_speech); +CREATE INDEX idx_flashcards_user_mastery ON flashcards(user_id, mastery_level); +CREATE INDEX idx_flashcards_user_favorite ON flashcards(user_id, is_favorite); + +-- 時間範圍索引 +CREATE INDEX idx_flashcards_user_created_at ON flashcards(user_id, created_at); +CREATE INDEX idx_flashcards_user_times_reviewed ON flashcards(user_id, times_reviewed); + +-- 複合索引 (重要查詢組合) +CREATE INDEX idx_flashcards_user_difficulty_mastery ON flashcards(user_id, difficulty_level, mastery_level); +CREATE INDEX idx_flashcards_user_favorite_created ON flashcards(user_id, is_favorite, created_at); + +-- 全文搜尋索引 (PostgreSQL) +CREATE INDEX idx_flashcards_fulltext ON flashcards +USING gin(to_tsvector('english', word || ' ' || translation || ' ' || definition || ' ' || example)); +``` + +#### 2.2 查詢優化 +```python +# 使用 SQLAlchemy 查詢優化 +def get_optimized_flashcards(): + # 使用子查詢優化計數 + subquery = apply_filters( + Flashcard.query.filter_by(user_id=current_user.id), + request.args + ).subquery() + + # 總數查詢 + total_count = db.session.query(subquery).count() + + # 分頁查詢 + query = db.session.query(Flashcard).select_from(subquery) + query = apply_sorting(query, request.args) + + flashcards = query.offset(offset).limit(limit).all() + + return flashcards, total_count +``` + +### 階段三:快取策略 + +#### 3.1 Redis快取 +```python +import redis +import json +import hashlib + +redis_client = redis.Redis(host='localhost', port=6379, db=0) + +def get_cache_key(user_id, params): + """生成快取鍵""" + cache_data = { + 'user_id': user_id, + 'params': dict(sorted(params.items())) + } + cache_string = json.dumps(cache_data, sort_keys=True) + return f"flashcards:{hashlib.md5(cache_string.encode()).hexdigest()}" + +def get_cached_flashcards(user_id, params): + """從快取獲取結果""" + cache_key = get_cache_key(user_id, params) + cached_data = redis_client.get(cache_key) + + if cached_data: + return json.loads(cached_data) + + return None + +def cache_flashcards(user_id, params, data, ttl=300): + """快取結果 (5分鐘TTL)""" + cache_key = get_cache_key(user_id, params) + redis_client.setex(cache_key, ttl, json.dumps(data)) +``` + +#### 3.2 快取失效策略 +```python +def invalidate_user_cache(user_id): + """當用戶資料變更時清除快取""" + pattern = f"flashcards:*user_id*{user_id}*" + + for key in redis_client.scan_iter(match=pattern): + redis_client.delete(key) + +# 在 CRUD 操作後調用 +@app.after_request +def invalidate_cache_on_mutation(response): + if request.method in ['POST', 'PUT', 'DELETE']: + invalidate_user_cache(current_user.id) + return response +``` + +--- + +## 🧪 API測試策略 + +### 測試案例設計 + +#### 基本功能測試 +```bash +# 1. 分頁測試 +curl "http://localhost:5008/api/flashcards?page=1&limit=2" +curl "http://localhost:5008/api/flashcards?page=2&limit=2" + +# 2. 篩選測試 +curl "http://localhost:5008/api/flashcards?difficultyLevel=A2" +curl "http://localhost:5008/api/flashcards?partOfSpeech=noun" +curl "http://localhost:5008/api/flashcards?masteryLevel=low" + +# 3. 排序測試 +curl "http://localhost:5008/api/flashcards?sortBy=word&sortOrder=asc" +curl "http://localhost:5008/api/flashcards?sortBy=masteryLevel&sortOrder=desc" + +# 4. 組合測試 +curl "http://localhost:5008/api/flashcards?difficultyLevel=A2&sortBy=word&page=1&limit=5" +``` + +#### 效能測試 +```python +# 負載測試 +import time +import requests + +def performance_test(): + start_time = time.time() + + response = requests.get('http://localhost:5008/api/flashcards', params={ + 'search': 'test', + 'difficultyLevel': 'A2', + 'sortBy': 'createdAt', + 'page': 1, + 'limit': 20 + }) + + end_time = time.time() + response_time = (end_time - start_time) * 1000 + + print(f"Response time: {response_time:.2f}ms") + return response_time + +# 目標:<300ms +``` + +--- + +## 📈 效能指標 + +### 成功標準 +- **回應時間**: < 300ms (95th percentile) +- **分頁查詢**: < 200ms +- **搜尋查詢**: < 500ms +- **快取命中率**: > 60% +- **資料庫連接**: < 100ms + +### 監控指標 +```python +# API監控中間件 +@app.before_request +def before_request(): + g.start_time = time.time() + +@app.after_request +def after_request(response): + response_time = (time.time() - g.start_time) * 1000 + + # 記錄慢查詢 + if response_time > 500: + logger.warning(f"Slow query: {request.url} - {response_time:.2f}ms") + + # 添加效能標頭 + response.headers['X-Response-Time'] = f"{response_time:.2f}ms" + + return response +``` + +--- + +## 🔄 實施計劃 + +### 第1週:基礎功能 +- ✅ 實作分頁功能 +- ✅ 實作基本篩選 +- ✅ 實作排序功能 +- ✅ API測試 + +### 第2週:優化與擴展 +- ✅ 建立資料庫索引 +- ✅ 實作進階篩選 +- ✅ 效能優化 +- ✅ 錯誤處理 + +### 第3週:快取與監控 +- ✅ 實作Redis快取 +- ✅ 效能監控 +- ✅ 負載測試 +- ✅ 文檔完善 + +這個完整的後端API策略確保: +- 🎯 **功能完整** - 支援所有前端需求 +- ⚡ **高效能** - 資料庫和快取優化 +- 🔒 **穩定性** - 完整的錯誤處理 +- 📊 **可監控** - 效能指標追蹤 +- 🧪 **可測試** - 完整的測試覆蓋 + +--- + +*文檔版本: 1.0* +*最後更新: 2025-09-24* \ No newline at end of file diff --git a/FRONTEND_ARCHITECTURE.md b/FRONTEND_ARCHITECTURE.md new file mode 100644 index 0000000..b8448bf --- /dev/null +++ b/FRONTEND_ARCHITECTURE.md @@ -0,0 +1,620 @@ +# 🎨 前端架構設計 + +## 🎯 設計原則 + +### 核心理念 +- **狀態驅動** - 單一真實來源 (Single Source of Truth) +- **組件分離** - 邏輯、展示、容器分離 +- **可預測性** - 明確的資料流向 +- **可測試性** - 易於單元測試和整合測試 +- **效能優化** - 避免不必要的重渲染 + +--- + +## 🏗️ 架構概覽 + +``` +src/ +├── hooks/ +│ ├── useFlashcardSearch.ts # 搜尋狀態管理 +│ ├── useUrlSync.ts # URL狀態同步 +│ ├── useDebounce.ts # 防抖 hook +│ └── useApi.ts # API調用封裝 +├── components/ +│ ├── Search/ +│ │ ├── SearchControls.tsx # 搜尋控制組件 +│ │ ├── FilterPanel.tsx # 篩選面板 +│ │ ├── SortControls.tsx # 排序控制 +│ │ └── SearchStats.tsx # 搜尋統計 +│ ├── Pagination/ +│ │ ├── PaginationControls.tsx +│ │ └── PageSizeSelector.tsx +│ └── FlashcardList/ +│ ├── FlashcardGrid.tsx +│ ├── FlashcardCard.tsx +│ └── EmptyState.tsx +├── types/ +│ ├── flashcard.ts +│ ├── search.ts +│ └── api.ts +└── utils/ + ├── apiCache.ts + ├── searchHelpers.ts + └── urlHelpers.ts +``` + +--- + +## 🔄 狀態管理架構 + +### 主要搜尋Hook設計 +```typescript +// hooks/useFlashcardSearch.ts + +export interface SearchFilters { + search: string; + difficultyLevel: string; + partOfSpeech: string; + masteryLevel: string; + favoritesOnly: boolean; + createdAfter?: string; + createdBefore?: string; + reviewCountMin?: number; + reviewCountMax?: number; +} + +export interface SortOptions { + sortBy: string; + sortOrder: 'asc' | 'desc'; +} + +export interface PaginationState { + currentPage: number; + pageSize: number; + totalPages: number; + totalCount: number; + hasNext: boolean; + hasPrev: boolean; +} + +export interface SearchState { + // 資料 + flashcards: Flashcard[]; + pagination: PaginationState; + + // UI 狀態 + loading: boolean; + error: string | null; + isInitialLoad: boolean; + + // 搜尋條件 + filters: SearchFilters; + sorting: SortOptions; + + // 元數據 + lastUpdated: Date | null; + cacheHit: boolean; +} + +export interface SearchActions { + // 篩選操作 + updateFilters: (filters: Partial) => void; + clearFilters: () => void; + resetFilters: () => void; + + // 排序操作 + updateSorting: (sorting: Partial) => void; + toggleSortOrder: () => void; + + // 分頁操作 + goToPage: (page: number) => void; + changePageSize: (size: number) => void; + goToNextPage: () => void; + goToPrevPage: () => void; + + // 資料操作 + refresh: () => Promise; + refetch: () => Promise; +} + +export const useFlashcardSearch = (): [SearchState, SearchActions] => { + // 狀態管理實作... +}; +``` + +### 實作細節 + +#### 1. 核心狀態管理 +```typescript +export const useFlashcardSearch = () => { + const [state, setState] = useState({ + flashcards: [], + pagination: { + currentPage: 1, + pageSize: 20, + totalPages: 0, + totalCount: 0, + hasNext: false, + hasPrev: false, + }, + loading: false, + error: null, + isInitialLoad: true, + filters: { + search: '', + difficultyLevel: '', + partOfSpeech: '', + masteryLevel: '', + favoritesOnly: false, + }, + sorting: { + sortBy: 'createdAt', + sortOrder: 'desc', + }, + lastUpdated: null, + cacheHit: false, + }); + + // 搜尋邏輯 + const executeSearch = useCallback(async () => { + setState(prev => ({ ...prev, loading: true, error: null })); + + try { + const params = { + ...state.filters, + ...state.sorting, + page: state.pagination.currentPage, + limit: state.pagination.pageSize, + }; + + const result = await flashcardsService.getFlashcards(params); + + if (result.success && result.data) { + setState(prev => ({ + ...prev, + flashcards: result.data.flashcards, + pagination: { + ...prev.pagination, + totalPages: result.data.pagination.total_pages, + totalCount: result.data.pagination.total_count, + hasNext: result.data.pagination.has_next, + hasPrev: result.data.pagination.has_prev, + }, + loading: false, + isInitialLoad: false, + lastUpdated: new Date(), + cacheHit: result.data.meta?.cache_hit || false, + })); + } else { + setState(prev => ({ + ...prev, + loading: false, + error: result.error || 'Failed to load flashcards', + })); + } + } catch (error) { + setState(prev => ({ + ...prev, + loading: false, + error: error instanceof Error ? error.message : 'Unknown error', + })); + } + }, [state.filters, state.sorting, state.pagination.currentPage, state.pagination.pageSize]); + + // 防抖搜尋 + const debouncedSearch = useDebounce(executeSearch, 300); + + // Actions + const updateFilters = useCallback((newFilters: Partial) => { + setState(prev => ({ + ...prev, + filters: { ...prev.filters, ...newFilters }, + pagination: { ...prev.pagination, currentPage: 1 }, // 重置頁碼 + })); + }, []); + + const updateSorting = useCallback((newSorting: Partial) => { + setState(prev => ({ + ...prev, + sorting: { ...prev.sorting, ...newSorting }, + pagination: { ...prev.pagination, currentPage: 1 }, + })); + }, []); + + const goToPage = useCallback((page: number) => { + setState(prev => ({ + ...prev, + pagination: { ...prev.pagination, currentPage: page }, + })); + }, []); + + const changePageSize = useCallback((pageSize: number) => { + setState(prev => ({ + ...prev, + pagination: { ...prev.pagination, pageSize, currentPage: 1 }, + })); + }, []); + + // 自動執行搜尋 + useEffect(() => { + if (state.filters.search) { + debouncedSearch(); + } else { + executeSearch(); + } + }, [state.filters, state.sorting, state.pagination.currentPage, state.pagination.pageSize]); + + return [ + state, + { + updateFilters, + updateSorting, + goToPage, + changePageSize, + clearFilters: () => updateFilters({ + search: '', + difficultyLevel: '', + partOfSpeech: '', + masteryLevel: '', + favoritesOnly: false, + }), + toggleSortOrder: () => updateSorting({ + sortOrder: state.sorting.sortOrder === 'asc' ? 'desc' : 'asc' + }), + goToNextPage: () => state.pagination.hasNext && goToPage(state.pagination.currentPage + 1), + goToPrevPage: () => state.pagination.hasPrev && goToPage(state.pagination.currentPage - 1), + refresh: executeSearch, + refetch: executeSearch, + }, + ]; +}; +``` + +#### 2. URL狀態同步 +```typescript +// hooks/useUrlSync.ts +export const useUrlSync = (searchState: SearchState, searchActions: SearchActions) => { + const router = useRouter(); + const searchParams = useSearchParams(); + + // 狀態 -> URL + useEffect(() => { + const params = new URLSearchParams(); + + // 只同步有值的參數 + if (searchState.filters.search) { + params.set('q', searchState.filters.search); + } + if (searchState.filters.difficultyLevel) { + params.set('level', searchState.filters.difficultyLevel); + } + if (searchState.filters.partOfSpeech) { + params.set('pos', searchState.filters.partOfSpeech); + } + if (searchState.filters.masteryLevel) { + params.set('mastery', searchState.filters.masteryLevel); + } + if (searchState.filters.favoritesOnly) { + params.set('favorites', 'true'); + } + if (searchState.sorting.sortBy !== 'createdAt') { + params.set('sort', searchState.sorting.sortBy); + } + if (searchState.sorting.sortOrder !== 'desc') { + params.set('order', searchState.sorting.sortOrder); + } + if (searchState.pagination.currentPage > 1) { + params.set('page', searchState.pagination.currentPage.toString()); + } + if (searchState.pagination.pageSize !== 20) { + params.set('size', searchState.pagination.pageSize.toString()); + } + + const queryString = params.toString(); + const newUrl = queryString ? `?${queryString}` : ''; + + // 避免無限循環 + if (window.location.search !== newUrl) { + router.push(newUrl, { scroll: false }); + } + }, [searchState, router]); + + // URL -> 狀態 (初始化) + useEffect(() => { + const initialState = { + search: searchParams.get('q') || '', + difficultyLevel: searchParams.get('level') || '', + partOfSpeech: searchParams.get('pos') || '', + masteryLevel: searchParams.get('mastery') || '', + favoritesOnly: searchParams.get('favorites') === 'true', + }; + + const initialSorting = { + sortBy: searchParams.get('sort') || 'createdAt', + sortOrder: (searchParams.get('order') as 'asc' | 'desc') || 'desc', + }; + + const initialPage = parseInt(searchParams.get('page') || '1'); + const initialPageSize = parseInt(searchParams.get('size') || '20'); + + // 只在初始化時設定 + if (searchState.isInitialLoad) { + searchActions.updateFilters(initialState); + searchActions.updateSorting(initialSorting); + if (initialPage > 1) { + searchActions.goToPage(initialPage); + } + if (initialPageSize !== 20) { + searchActions.changePageSize(initialPageSize); + } + } + }, [searchParams, searchState.isInitialLoad]); +}; +``` + +#### 3. 組件架構設計 +```typescript +// components/Search/SearchControls.tsx +interface SearchControlsProps { + filters: SearchFilters; + sorting: SortOptions; + onFiltersChange: (filters: Partial) => void; + onSortingChange: (sorting: Partial) => void; + onClearFilters: () => void; + loading?: boolean; +} + +export const SearchControls: React.FC = ({ + filters, + sorting, + onFiltersChange, + onSortingChange, + onClearFilters, + loading = false, +}) => { + const [showAdvanced, setShowAdvanced] = useState(false); + + return ( +
+ {/* 基本搜尋 */} +
+ onFiltersChange({ search })} + placeholder="搜尋詞彙、翻譯或定義..." + loading={loading} + /> + + +
+ + {/* 進階篩選 */} + {showAdvanced && ( + + )} + + {/* 切換按鈕 */} + +
+ ); +}; +``` + +#### 4. API服務擴展 +```typescript +// lib/services/flashcards.ts (擴展版本) + +export interface FlashcardQueryParams extends SearchFilters, SortOptions { + page?: number; + limit?: number; +} + +export interface FlashcardQueryResponse { + flashcards: Flashcard[]; + pagination: { + current_page: number; + total_pages: number; + total_count: number; + page_size: number; + has_next: boolean; + has_prev: boolean; + }; + meta?: { + query_time_ms: number; + cache_hit: boolean; + }; +} + +class FlashcardsService { + private cache = new Map(); + + async getFlashcards(params: FlashcardQueryParams): Promise> { + try { + // 建構查詢參數 + const queryParams = new URLSearchParams(); + + // 只添加有值的參數 + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== '' && value !== false) { + if (typeof value === 'boolean') { + queryParams.append(key, 'true'); + } else { + queryParams.append(key, value.toString()); + } + } + }); + + const queryString = queryParams.toString(); + const endpoint = `/flashcards${queryString ? `?${queryString}` : ''}`; + + // 檢查快取 + const cachedResult = this.getFromCache(endpoint); + if (cachedResult) { + return { + success: true, + data: { ...cachedResult, meta: { ...cachedResult.meta, cache_hit: true } } + }; + } + + // API調用 + const response = await this.makeRequest>(endpoint); + + // 快取結果 + if (response.success && response.data) { + this.saveToCache(endpoint, response.data); + } + + return response; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch flashcards', + }; + } + } + + private getFromCache(key: string): FlashcardQueryResponse | null { + const entry = this.cache.get(key); + if (entry && Date.now() - entry.timestamp < 300000) { // 5分鐘快取 + return entry.data; + } + return null; + } + + private saveToCache(key: string, data: FlashcardQueryResponse): void { + this.cache.set(key, { + data, + timestamp: Date.now(), + }); + } +} +``` + +--- + +## 🧪 測試策略 + +### 單元測試 +```typescript +// __tests__/hooks/useFlashcardSearch.test.ts +describe('useFlashcardSearch', () => { + it('should initialize with default state', () => { + const { result } = renderHook(() => useFlashcardSearch()); + const [state] = result.current; + + expect(state.loading).toBe(false); + expect(state.flashcards).toEqual([]); + expect(state.filters.search).toBe(''); + }); + + it('should update filters and reset page', () => { + const { result } = renderHook(() => useFlashcardSearch()); + const [, actions] = result.current; + + act(() => { + actions.goToPage(3); + actions.updateFilters({ search: 'test' }); + }); + + const [newState] = result.current; + expect(newState.filters.search).toBe('test'); + expect(newState.pagination.currentPage).toBe(1); + }); +}); +``` + +### 整合測試 +```typescript +// __tests__/integration/FlashcardsPage.test.tsx +describe('FlashcardsPage Integration', () => { + it('should load and display flashcards', async () => { + const mockFlashcards = [ + { id: '1', word: 'test', translation: '測試' } + ]; + + mockApiResponse(mockFlashcards); + + render(); + + await waitFor(() => { + expect(screen.getByText('test')).toBeInTheDocument(); + }); + }); +}); +``` + +--- + +## 📈 效能優化 + +### React優化策略 +```typescript +// 使用 React.memo 避免不必要渲染 +export const FlashcardCard = React.memo(({ flashcard, onEdit, onDelete }) => { + // 組件實作... +}); + +// 使用 useMemo 快取計算結果 +const filteredOptions = useMemo(() => { + return options.filter(option => option.available); +}, [options]); + +// 使用 useCallback 穩定函數引用 +const handleSearch = useCallback((term: string) => { + onSearch(term); +}, [onSearch]); +``` + +### 虛擬滾動 (大量數據時) +```typescript +import { FixedSizeList as List } from 'react-window'; + +export const VirtualFlashcardList: React.FC<{ + flashcards: Flashcard[]; + height: number; +}> = ({ flashcards, height }) => { + const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => ( +
+ +
+ ); + + return ( + + {Row} + + ); +}; +``` + +--- + +這個前端架構確保: +- 🎯 **清晰的狀態管理** - 單一真實來源 +- ⚡ **高效能** - 防抖、快取、虛擬滾動 +- 🔄 **URL同步** - 支援書籤和分享 +- 🧪 **可測試** - 完整的測試覆蓋 +- 🔧 **可維護** - 組件分離、類型安全 + +--- + +*文檔版本: 1.0* +*最後更新: 2025-09-24* \ No newline at end of file diff --git a/frontend/app/flashcards/page.tsx b/frontend/app/flashcards/page.tsx index 9e81f7b..07db553 100644 --- a/frontend/app/flashcards/page.tsx +++ b/frontend/app/flashcards/page.tsx @@ -1,930 +1,696 @@ -'use client' - -import { useState, useEffect, useCallback, useRef } from 'react' -import Link from 'next/link' -import { ProtectedRoute } from '@/components/ProtectedRoute' -import { Navigation } from '@/components/Navigation' -import { FlashcardForm } from '@/components/FlashcardForm' -import { useToast } from '@/components/Toast' -// import { flashcardsService, type CardSet, type Flashcard } from '@/lib/services/flashcards' -import { flashcardsService, type Flashcard } from '@/lib/services/flashcards' - -// 移除不需要的型別定義,直接使用 import 的 Flashcard 型別 -import { useRouter } from 'next/navigation' - -function FlashcardsContent() { - const router = useRouter() - const toast = useToast() - const [activeTab, setActiveTab] = useState('all-cards') - // const [selectedSet, setSelectedSet] = useState(null) // 移除 CardSets 相關 - const [searchInput, setSearchInput] = useState('') // 輸入框顯示用狀態 - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('') // API 查詢用狀態 - const [showAdvancedSearch, setShowAdvancedSearch] = useState(false) - const [searchFilters, setSearchFilters] = useState({ - cefrLevel: '', - partOfSpeech: '', - masteryLevel: '', - onlyFavorites: false - }) - - // Real data from API - // const [cardSets, setCardSets] = useState([]) // 移除 CardSets 狀態 - const [flashcards, setFlashcards] = useState([]) - const [loading, setLoading] = useState(true) // 初始載入狀態 - const [isSearching, setIsSearching] = useState(false) // 搜尋載入狀態 - const [error, setError] = useState(null) - const [totalCounts, setTotalCounts] = useState({ all: 0, favorites: 0 }) - - // useRef 追蹤輸入框 DOM 元素 - const searchInputRef = useRef(null) - - // 臨時使用學習功能的例句圖片作為測試 - const getExampleImage = (word: string): string => { - const availableImages = [ - '/images/examples/bring_up.png', - '/images/examples/instinct.png', - '/images/examples/warrant.png' - ] - - const imageMap: {[key: string]: string} = { - 'brought': '/images/examples/bring_up.png', - 'instincts': '/images/examples/instinct.png', - 'warrants': '/images/examples/warrant.png', - 'hello': '/images/examples/bring_up.png', - 'beautiful': '/images/examples/instinct.png', - 'understand': '/images/examples/warrant.png', - 'elaborate': '/images/examples/bring_up.png', - 'sophisticated': '/images/examples/instinct.png', - 'ubiquitous': '/images/examples/warrant.png' - } - - // 根據詞彙返回對應圖片,如果沒有則根據字母分配 - const mappedImage = imageMap[word?.toLowerCase()] - if (mappedImage) return mappedImage - - // 根據首字母分配圖片 - const firstChar = (word || 'a')[0].toLowerCase() - const charCode = firstChar.charCodeAt(0) - 97 // a=0, b=1, c=2... - const imageIndex = charCode % availableImages.length - - return availableImages[imageIndex] - } - - // Form states - const [showForm, setShowForm] = useState(false) - const [editingCard, setEditingCard] = useState(null) - - // 移除假資料,現在完全使用真實 API 資料 - - // 載入總數統計 - const loadTotalCounts = async () => { - try { - // 載入所有詞卡數量 - const allResult = await flashcardsService.getFlashcards() - const allCount = allResult.success && allResult.data ? allResult.data.count : 0 - - // 載入收藏詞卡數量 - const favoritesResult = await flashcardsService.getFlashcards(undefined, true) - const favoritesCount = favoritesResult.success && favoritesResult.data ? favoritesResult.data.count : 0 - - setTotalCounts({ all: allCount, favorites: favoritesCount }) - } catch (err) { - console.error('載入統計失敗:', err) - } - } - - // Load data from API - useEffect(() => { - // 載入詞卡和統計 - loadFlashcards(true) // 初始載入 - loadTotalCounts() - }, []) - - // 防抖邏輯:將輸入轉換為查詢詞 - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchTerm(searchInput) - }, 300) - - return () => clearTimeout(timer) - }, [searchInput]) - - // 當查詢條件變更時重新載入資料 - useEffect(() => { - loadFlashcards(false) // 搜尋載入,不觸發 loading 狀態 - }, [debouncedSearchTerm, searchFilters, activeTab]) - - // 在搜尋完成後恢復焦點 - useEffect(() => { - if (!isSearching && searchInputRef.current && document.activeElement !== searchInputRef.current) { - // 只有當搜尋框失去焦點且用戶正在輸入時才恢復焦點 - const wasFocused = searchInput.length > 0 && !loading - if (wasFocused) { - const currentPosition = searchInputRef.current.selectionStart || searchInput.length - searchInputRef.current.focus() - searchInputRef.current.setSelectionRange(currentPosition, currentPosition) - } - } - }, [isSearching, loading, searchInput]) - - // 暫時移除 CardSets 功能,直接設定空陣列 - // const loadCardSets = async () => { - // setCardSets([]) - // } - - const loadFlashcards = useCallback(async (isInitialLoad = false) => { - try { - // 區分初始載入和搜尋載入狀態 - if (isInitialLoad) { - setLoading(true) - } else { - setIsSearching(true) - } - setError(null) // 清除之前的錯誤 - - // 使用進階篩選參數呼叫 API - const result = await flashcardsService.getFlashcards( - debouncedSearchTerm || undefined, - activeTab === 'favorites', - searchFilters.cefrLevel || undefined, - searchFilters.partOfSpeech || undefined, - searchFilters.masteryLevel || undefined - ) - - if (result.success && result.data) { - setFlashcards(result.data.flashcards) - console.log('✅ 詞卡載入成功:', result.data.flashcards.length, '個詞卡') - } else { - setError(result.error || 'Failed to load flashcards') - console.error('❌ 詞卡載入失敗:', result.error) - } - } catch (err) { - const errorMessage = 'Failed to load flashcards' - setError(errorMessage) - console.error('❌ 詞卡載入異常:', err) - } finally { - // 清除對應的載入狀態 - if (isInitialLoad) { - setLoading(false) - } else { - setIsSearching(false) - } - } - }, [debouncedSearchTerm, activeTab, searchFilters]) - - // 移除 selectedSet 依賴的 useEffect - // useEffect(() => { - // loadFlashcards() - // }, [selectedSet]) - - // Handle form operations - const handleFormSuccess = async () => { - setShowForm(false) - setEditingCard(null) - await loadFlashcards(false) // 表單操作後重新載入 - await loadTotalCounts() - // 移除 loadCardSets() 調用 - } - - const handleEdit = (card: Flashcard) => { - setEditingCard(card) - setShowForm(true) - } - - const handleDelete = async (card: Flashcard) => { - if (!confirm(`確定要刪除詞卡「${card.word}」嗎?`)) { - return - } - - try { - const result = await flashcardsService.deleteFlashcard(card.id) - if (result.success) { - await loadFlashcards(false) // 刪除操作後重新載入 - await loadTotalCounts() - toast.success(`詞卡「${card.word}」已刪除`) - } else { - toast.error(result.error || '刪除失敗') - } - } catch (err) { - toast.error('刪除失敗,請重試') - } - } - - const handleToggleFavorite = async (card: any) => { - try { - // 如果是假資料,只更新本地狀態 - if (card.id.startsWith('mock')) { - // 模擬資料暫時只顯示提示,實際狀態更新需要實作 - toast.success(`${card.isFavorite ? '已取消收藏' : '已加入收藏'}「${card.word}」`) - return - } - - // 真實API調用 - const result = await flashcardsService.toggleFavorite(card.id) - if (result.success) { - // 重新載入詞卡以反映最新的收藏狀態 - await loadFlashcards(false) // 收藏操作後重新載入 - // 重新載入統計數量 - await loadTotalCounts() - toast.success(`${card.isFavorite ? '已取消收藏' : '已加入收藏'}「${card.word}」`) - } else { - toast.error(result.error || '操作失敗') - } - } catch (err) { - toast.error('操作失敗,請重試') - } - } - - // 獲取CEFR等級顏色 - const getCEFRColor = (level: string) => { - switch (level) { - case 'A1': return 'bg-green-100 text-green-700 border-green-200' // 淺綠 - 最基礎 - case 'A2': return 'bg-blue-100 text-blue-700 border-blue-200' // 淺藍 - 基礎 - case 'B1': return 'bg-yellow-100 text-yellow-700 border-yellow-200' // 淺黃 - 中級 - case 'B2': return 'bg-orange-100 text-orange-700 border-orange-200' // 淺橙 - 中高級 - case 'C1': return 'bg-red-100 text-red-700 border-red-200' // 淺紅 - 高級 - case 'C2': return 'bg-purple-100 text-purple-700 border-purple-200' // 淺紫 - 精通 - default: return 'bg-gray-100 text-gray-700 border-gray-200' // 預設灰色 - } - } - - - // 由於後端已處理篩選,直接使用 API 回傳的結果 - const filteredCards = flashcards // 直接使用從 API 取得的已篩選結果 - - // 清除所有篩選 - const clearAllFilters = () => { - setSearchInput('') - setDebouncedSearchTerm('') - setSearchFilters({ - cefrLevel: '', - partOfSpeech: '', - masteryLevel: '', - onlyFavorites: false - }) - } - - // 檢查是否有活動篩選 - const hasActiveFilters = debouncedSearchTerm || - searchFilters.cefrLevel || - searchFilters.partOfSpeech || - searchFilters.masteryLevel || - searchFilters.onlyFavorites - - // 搜尋結果高亮函數 - const highlightSearchTerm = (text: string, debouncedSearchTerm: string) => { - if (!debouncedSearchTerm || !text) return text - - const regex = new RegExp(`(${debouncedSearchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi') - const parts = text.split(regex) - - return parts.map((part, index) => - regex.test(part) ? ( - - {part} - - ) : ( - part - ) - ) - } - - // Add loading and error states - if (loading) { - return ( -
-
載入中...
-
- ) - } - - if (error) { - return ( -
-
{error}
-
- ) - } - - return ( -
- {/* Navigation */} - - - {/* Main Content */} -
- {/* Page Header */} -
-
-

我的詞卡

-
-
- - - AI 生成詞卡 - -
-
- - {/* 簡化的Tabs - 移除卡組功能 */} -
- - -
- {/* 進階搜尋區域 */} -
-
-

搜尋詞卡

- -
- - {/* 主要搜尋框 */} -
- setSearchInput(e.target.value)} - placeholder="搜尋詞彙、翻譯或定義..." - className="w-full pl-12 pr-20 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent text-base" - onKeyDown={(e) => { - if (e.key === 'Escape') { - setSearchInput('') - setDebouncedSearchTerm('') - } - }} - /> -
- - - -
- {(searchInput || hasActiveFilters) && ( -
- - {filteredCards.length} 結果 - - -
- )} -
- - {/* 進階篩選選項 */} - {showAdvancedSearch && ( -
-
- {/* CEFR等級篩選 */} -
- - -
- - {/* 詞性篩選 */} -
- - -
- - {/* 掌握度篩選 */} -
- - -
- - {/* 收藏篩選 */} -
- - -
-
- - {/* 快速篩選按鈕 */} -
- 快速篩選: - - - - - {hasActiveFilters && ( - - )} -
-
- )} - - {/* 搜尋結果統計 */} - {(debouncedSearchTerm || hasActiveFilters) && ( -
-
- - - - - 找到 {filteredCards.length} 個詞卡 - {debouncedSearchTerm && ( - ,包含 "{debouncedSearchTerm}" - )} - -
- {hasActiveFilters && ( - - )} -
- )} -
- - - {/* Favorites Tab */} - {activeTab === 'favorites' && ( -
-
-

共 {filteredCards.length} 個詞卡

-
- - {filteredCards.length === 0 ? ( -
-
-

還沒有收藏的詞卡

-

在詞卡列表中點擊星星按鈕來收藏重要的詞彙

-
- ) : ( -
- {filteredCards.map(card => ( -
-
- {/* 收藏詞卡內容 - 與普通詞卡相同的佈局 */} -
-
- - {(card as any).difficultyLevel || 'A1'} - -
- -
-
- {`${card.word} { - const target = e.target as HTMLImageElement - target.style.display = 'none' - target.parentElement!.innerHTML = ` -
- - - - 例句圖 -
- ` - }} - /> -
- -
-
-

- {debouncedSearchTerm ? highlightSearchTerm(card.word || '未設定', debouncedSearchTerm) : (card.word || '未設定')} -

- - {card.partOfSpeech || 'unknown'} - -
- -
- - {debouncedSearchTerm ? highlightSearchTerm(card.translation || '未設定', debouncedSearchTerm) : (card.translation || '未設定')} - - {card.pronunciation && ( -
- {card.pronunciation} - -
- )} -
- -
- 創建: {new Date(card.createdAt).toLocaleDateString()} - 掌握度: {card.masteryLevel}% -
-
-
- - {/* 右側:重新設計的操作按鈕區 */} -
- {/* 收藏按鈕 */} - - - {/* 編輯按鈕 */} - - - {/* 刪除按鈕 */} - - - {/* 查看詳情按鈕 - 導航到詳細頁面 */} - -
-
-
-
- ))} -
- )} -
- )} - - {/* All Cards Tab */} - {activeTab === 'all-cards' && ( -
-
-

共 {filteredCards.length} 個詞卡

-
- - - {filteredCards.length === 0 ? ( -
-

沒有找到詞卡

- - 創建新詞卡 - -
- ) : ( -
- {filteredCards.map(card => ( -
-
-
- {/* 詞卡右上角CEFR標註 */} -
- - {(card as any).difficultyLevel || 'A1'} - -
- - {/* 左側:詞彙基本信息 */} -
- {/* 例句圖片 - 超大尺寸 */} -
- {`${card.word} { - // 圖片載入失敗時顯示佔位符 - const target = e.target as HTMLImageElement - target.style.display = 'none' - target.parentElement!.innerHTML = ` -
- - - - 例句圖 -
- ` - }} - /> -
- -
-
-

- {debouncedSearchTerm ? highlightSearchTerm(card.word || '未設定', debouncedSearchTerm) : (card.word || '未設定')} -

- - {card.partOfSpeech || 'unknown'} - -
- -
- - {debouncedSearchTerm ? highlightSearchTerm(card.translation || '未設定', debouncedSearchTerm) : (card.translation || '未設定')} - - {card.pronunciation && ( -
- {card.pronunciation} - -
- )} -
- - {/* 簡要統計 */} -
- 創建: {new Date(card.createdAt).toLocaleDateString()} - 掌握度: {card.masteryLevel}% -
-
-
- - {/* 右側:操作按鈕 */} -
- - {/* 重新設計的操作按鈕區 */} -
- {/* 收藏按鈕 */} - - - {/* 編輯按鈕 */} - - - {/* 刪除按鈕 */} - - - {/* 查看詳情按鈕 - 導航到詳細頁面 */} - -
-
-
-
-
- ))} -
- )} -
- )} -
- - {/* Flashcard Form Modal */} - {showForm && ( - { - setShowForm(false) - setEditingCard(null) - }} - /> - )} - - {/* Toast 通知系統 */} - -
- ) -} - -export default function FlashcardsPage() { - return ( - - - - ) +'use client' + +import { useState, useEffect } from 'react' +import Link from 'next/link' +import { useRouter } from 'next/navigation' +import { ProtectedRoute } from '@/components/ProtectedRoute' +import { Navigation } from '@/components/Navigation' +import { FlashcardForm } from '@/components/FlashcardForm' +import { useToast } from '@/components/Toast' +import { flashcardsService, type Flashcard } from '@/lib/services/flashcards' +import { useFlashcardSearch, type SearchActions } from '@/hooks/useFlashcardSearch' + +// 重構後的FlashcardsContent組件 +function FlashcardsContent() { + const router = useRouter() + const toast = useToast() + const [activeTab, setActiveTab] = useState<'all-cards' | 'favorites'>('all-cards') + const [showForm, setShowForm] = useState(false) + const [editingCard, setEditingCard] = useState(null) + const [showAdvancedSearch, setShowAdvancedSearch] = useState(false) + const [totalCounts, setTotalCounts] = useState({ all: 0, favorites: 0 }) + + // 使用新的搜尋Hook + const [searchState, searchActions] = useFlashcardSearch(activeTab) + + // 初始化數據載入 + useEffect(() => { + loadTotalCounts() + }, []) + + // 載入總數統計 + const loadTotalCounts = async () => { + try { + // 載入所有詞卡數量 + const allResult = await flashcardsService.getFlashcards() + const allCount = allResult.success && allResult.data ? allResult.data.count : 0 + + // 載入收藏詞卡數量 + const favoritesResult = await flashcardsService.getFlashcards(undefined, true) + const favoritesCount = favoritesResult.success && favoritesResult.data ? favoritesResult.data.count : 0 + + setTotalCounts({ all: allCount, favorites: favoritesCount }) + } catch (err) { + console.error('載入統計失敗:', err) + } + } + + // 處理表單操作 + const handleFormSuccess = async () => { + setShowForm(false) + setEditingCard(null) + await searchActions.refresh() + await loadTotalCounts() + } + + const handleEdit = (card: Flashcard) => { + setEditingCard(card) + setShowForm(true) + } + + const handleDelete = async (card: Flashcard) => { + if (!confirm(`確定要刪除詞卡「${card.word}」嗎?`)) { + return + } + + try { + const result = await flashcardsService.deleteFlashcard(card.id) + if (result.success) { + await searchActions.refresh() + await loadTotalCounts() + toast.success(`詞卡「${card.word}」已刪除`) + } else { + toast.error(result.error || '刪除失敗') + } + } catch (err) { + toast.error('刪除失敗,請重試') + } + } + + const handleToggleFavorite = async (card: Flashcard) => { + try { + const result = await flashcardsService.toggleFavorite(card.id) + if (result.success) { + await searchActions.refresh() + await loadTotalCounts() + toast.success(`${card.isFavorite ? '已取消收藏' : '已加入收藏'}「${card.word}」`) + } else { + toast.error(result.error || '操作失敗') + } + } catch (err) { + toast.error('操作失敗,請重試') + } + } + + // 獲取CEFR等級顏色 + const getCEFRColor = (level: string) => { + switch (level) { + case 'A1': return 'bg-green-100 text-green-700 border-green-200' + case 'A2': return 'bg-blue-100 text-blue-700 border-blue-200' + case 'B1': return 'bg-yellow-100 text-yellow-700 border-yellow-200' + case 'B2': return 'bg-orange-100 text-orange-700 border-orange-200' + case 'C1': return 'bg-red-100 text-red-700 border-red-200' + case 'C2': return 'bg-purple-100 text-purple-700 border-purple-200' + default: return 'bg-gray-100 text-gray-700 border-gray-200' + } + } + + // 搜尋結果高亮函數 + const highlightSearchTerm = (text: string, searchTerm: string) => { + if (!searchTerm || !text) return text + + const regex = new RegExp(`(${searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi') + const parts = text.split(regex) + + return parts.map((part, index) => + regex.test(part) ? ( + + {part} + + ) : ( + part + ) + ) + } + + // 載入狀態處理 + if (searchState.loading && searchState.isInitialLoad) { + return ( +
+
載入中...
+
+ ) + } + + if (searchState.error) { + return ( +
+
{searchState.error}
+
+ ) + } + + return ( +
+ {/* Navigation */} + + + {/* Main Content */} +
+ {/* Page Header */} +
+
+

我的詞卡

+
+
+ + + AI 生成詞卡 + +
+
+ + {/* Tabs */} +
+ + +
+ + {/* Search Controls */} + + + {/* Search Results */} + + + {/* Pagination Controls */} + +
+ + {/* Form Modal */} + {showForm && ( + { + setShowForm(false) + setEditingCard(null) + }} + /> + )} + + {/* Toast */} + +
+ ) +} + +// 搜尋控制組件 +interface SearchControlsProps { + searchState: any + searchActions: SearchActions + showAdvancedSearch: boolean + setShowAdvancedSearch: (show: boolean) => void +} + +function SearchControls({ searchState, searchActions, showAdvancedSearch, setShowAdvancedSearch }: SearchControlsProps) { + return ( +
+
+

搜尋詞卡

+
+ {/* 排序控件 */} +
+ 排序: + + +
+ + +
+
+ + {/* 主要搜尋框 */} +
+ searchActions.updateFilters({ search: e.target.value })} + placeholder="搜尋詞彙、翻譯或定義..." + className="w-full pl-12 pr-20 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent text-base" + onKeyDown={(e) => { + if (e.key === 'Escape') { + searchActions.clearFilters() + } + }} + /> +
+ + + +
+ {(searchState.filters.search || (searchState as any).hasActiveFilters) && ( +
+ + {searchState.pagination.totalCount} 結果 + + +
+ )} +
+ + {/* 進階篩選面板 */} + {showAdvancedSearch && ( +
+
+ {/* CEFR等級篩選 */} +
+ + +
+ + {/* 詞性篩選 */} +
+ + +
+ + {/* 掌握度篩選 */} +
+ + +
+ + {/* 收藏篩選 */} +
+ + +
+
+
+ )} +
+ ) +} + +// 搜尋結果組件 +interface SearchResultsProps { + searchState: any + activeTab: string + onEdit: (card: Flashcard) => void + onDelete: (card: Flashcard) => void + onToggleFavorite: (card: Flashcard) => void + getCEFRColor: (level: string) => string + highlightSearchTerm: (text: string, term: string) => React.ReactNode + router: any +} + +function SearchResults({ + searchState, + activeTab, + onEdit, + onDelete, + onToggleFavorite, + getCEFRColor, + highlightSearchTerm, + router +}: SearchResultsProps) { + if (searchState.flashcards.length === 0) { + return ( +
+ {activeTab === 'favorites' ? ( + <> +
+

還沒有收藏的詞卡

+

在詞卡列表中點擊星星按鈕來收藏重要的詞彙

+ + ) : ( + <> +

沒有找到詞卡

+ + 創建新詞卡 + + + )} +
+ ) + } + + return ( +
+ {searchState.flashcards.map((card: Flashcard) => ( + onEdit(card)} + onDelete={() => onDelete(card)} + onToggleFavorite={() => onToggleFavorite(card)} + getCEFRColor={getCEFRColor} + highlightSearchTerm={highlightSearchTerm} + router={router} + /> + ))} +
+ ) +} + +// 詞卡項目組件 +interface FlashcardItemProps { + card: Flashcard + searchTerm: string + onEdit: () => void + onDelete: () => void + onToggleFavorite: () => void + getCEFRColor: (level: string) => string + highlightSearchTerm: (text: string, term: string) => React.ReactNode + router: any +} + +function FlashcardItem({ card, searchTerm, onEdit, onDelete, onToggleFavorite, getCEFRColor, highlightSearchTerm, router }: FlashcardItemProps) { + return ( +
+
+
+ {/* CEFR標註 */} +
+ + {(card as any).difficultyLevel || 'A1'} + +
+ +
+ {/* 詞卡信息 */} +
+
+

+ {searchTerm ? highlightSearchTerm(card.word || '未設定', searchTerm) : (card.word || '未設定')} +

+ + {card.partOfSpeech || 'unknown'} + +
+ +
+ + {searchTerm ? highlightSearchTerm(card.translation || '未設定', searchTerm) : (card.translation || '未設定')} + + {card.pronunciation && ( +
+ {card.pronunciation} +
+ )} +
+ +
+ 創建: {new Date(card.createdAt).toLocaleDateString()} + 掌握度: {card.masteryLevel}% +
+
+
+ + {/* 操作按鈕 */} +
+ {/* 收藏按鈕 */} + + + {/* 編輯按鈕 */} + + + {/* 刪除按鈕 */} + + + {/* 詳細按鈕 */} + +
+
+
+
+ ) +} + +// 分頁控制組件 +interface PaginationControlsProps { + searchState: any + searchActions: SearchActions +} + +function PaginationControls({ searchState, searchActions }: PaginationControlsProps) { + if (searchState.pagination.totalPages <= 1) { + return null + } + + return ( +
+
+ + 第 {searchState.pagination.currentPage} 頁,共 {searchState.pagination.totalPages} 頁 + +
+ 每頁顯示: + +
+
+
+ {/* 上一頁 */} + + + {/* 頁碼 */} +
+ {[...Array(Math.min(5, searchState.pagination.totalPages))].map((_, index) => { + let pageNum + if (searchState.pagination.totalPages <= 5) { + pageNum = index + 1 + } else if (searchState.pagination.currentPage <= 3) { + pageNum = index + 1 + } else if (searchState.pagination.currentPage >= searchState.pagination.totalPages - 2) { + pageNum = searchState.pagination.totalPages - 4 + index + } else { + pageNum = searchState.pagination.currentPage - 2 + index + } + + return ( + + ) + })} +
+ + {/* 下一頁 */} + +
+
+ ) +} + +export default function FlashcardsPage() { + return ( + + + + ) } \ No newline at end of file diff --git a/frontend/hooks/useAudio.ts b/frontend/hooks/useAudio.ts index 28715ee..e65e830 100644 --- a/frontend/hooks/useAudio.ts +++ b/frontend/hooks/useAudio.ts @@ -106,7 +106,8 @@ export function useAudio() { // 如果沒有提供 URL,嘗試生成 if (!urlToPlay && request) { - urlToPlay = await generateAudio(request); + const generatedUrl = await generateAudio(request); + urlToPlay = generatedUrl || undefined; if (!urlToPlay) return false; } diff --git a/frontend/hooks/useDebounce.ts b/frontend/hooks/useDebounce.ts new file mode 100644 index 0000000..fe7d38e --- /dev/null +++ b/frontend/hooks/useDebounce.ts @@ -0,0 +1,82 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; + +/** + * useDebounce Hook + * + * 創建一個防抖版本的函數,在指定延遲後執行 + * 如果在延遲期間再次調用,會取消前一次並重新開始計時 + * + * @param callback 要防抖的函數 + * @param delay 延遲時間(毫秒) + * @returns 防抖後的函數 + */ +export const useDebounce = any>( + callback: T, + delay: number +): T => { + const timeoutRef = useRef(null); + + const debouncedCallback = useCallback( + (...args: Parameters) => { + // 清除之前的定時器 + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + // 設置新的定時器 + timeoutRef.current = setTimeout(() => { + callback(...args); + }, delay); + }, + [callback, delay] + ) as T; + + // 清理函數,組件卸載時清除定時器 + const cancel = useCallback(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }, []); + + // 為返回的函數添加 cancel 方法 + (debouncedCallback as any).cancel = cancel; + + return debouncedCallback; +}; + +/** + * useDebouncedValue Hook + * + * 創建一個防抖版本的值,只有在值穩定一段時間後才更新 + * + * @param value 要防抖的值 + * @param delay 延遲時間(毫秒) + * @returns 防抖後的值 + */ +export const useDebouncedValue = (value: T, delay: number): T => { + const [debouncedValue, setDebouncedValue] = useState(value); + const timeoutRef = useRef(null); + + useEffect(() => { + // 清除之前的定時器 + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + // 設置新的定時器 + timeoutRef.current = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + // 清理函數 + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, [value, delay]); + + return debouncedValue; +}; + diff --git a/frontend/hooks/useFlashcardSearch.ts b/frontend/hooks/useFlashcardSearch.ts new file mode 100644 index 0000000..476d444 --- /dev/null +++ b/frontend/hooks/useFlashcardSearch.ts @@ -0,0 +1,374 @@ +import { useState, useCallback, useEffect, useMemo } from 'react'; +import { flashcardsService, type Flashcard } from '@/lib/services/flashcards'; +import { useDebounce } from './useDebounce'; + +// 類型定義 +export interface SearchFilters { + search: string; + difficultyLevel: string; + partOfSpeech: string; + masteryLevel: string; + favoritesOnly: boolean; + createdAfter?: string; + createdBefore?: string; + reviewCountMin?: number; + reviewCountMax?: number; +} + +export interface SortOptions { + sortBy: string; + sortOrder: 'asc' | 'desc'; +} + +export interface PaginationState { + currentPage: number; + pageSize: number; + totalPages: number; + totalCount: number; + hasNext: boolean; + hasPrev: boolean; +} + +export interface SearchState { + // 資料 + flashcards: Flashcard[]; + pagination: PaginationState; + + // UI 狀態 + loading: boolean; + error: string | null; + isInitialLoad: boolean; + + // 搜尋條件 + filters: SearchFilters; + sorting: SortOptions; + + // 元數據 + lastUpdated: Date | null; + cacheHit: boolean; +} + +export interface SearchActions { + // 篩選操作 + updateFilters: (filters: Partial) => void; + clearFilters: () => void; + resetFilters: () => void; + + // 排序操作 + updateSorting: (sorting: Partial) => void; + toggleSortOrder: () => void; + + // 分頁操作 + goToPage: (page: number) => void; + changePageSize: (size: number) => void; + goToNextPage: () => void; + goToPrevPage: () => void; + + // 資料操作 + refresh: () => Promise; + refetch: () => Promise; +} + +// 初始狀態 +const initialState: SearchState = { + flashcards: [], + pagination: { + currentPage: 1, + pageSize: 20, + totalPages: 0, + totalCount: 0, + hasNext: false, + hasPrev: false, + }, + loading: false, + error: null, + isInitialLoad: true, + filters: { + search: '', + difficultyLevel: '', + partOfSpeech: '', + masteryLevel: '', + favoritesOnly: false, + }, + sorting: { + sortBy: 'createdAt', + sortOrder: 'desc', + }, + lastUpdated: null, + cacheHit: false, +}; + +export const useFlashcardSearch = (activeTab: 'all-cards' | 'favorites' = 'all-cards'): [SearchState, SearchActions] => { + const [state, setState] = useState(initialState); + + // 搜尋邏輯 + const executeSearch = useCallback(async () => { + setState(prev => ({ ...prev, loading: true, error: null })); + + try { + // 構建 API 參數 + const apiParams = { + search: state.filters.search || undefined, + favoritesOnly: activeTab === 'favorites' || state.filters.favoritesOnly, + difficultyLevel: state.filters.difficultyLevel || undefined, + partOfSpeech: state.filters.partOfSpeech || undefined, + masteryLevel: state.filters.masteryLevel || undefined, + sortBy: state.sorting.sortBy, + sortOrder: state.sorting.sortOrder, + page: state.pagination.currentPage, + limit: state.pagination.pageSize, + }; + + // 暫時不發送difficultyLevel給後端,因為後端不支援 + const result = await flashcardsService.getFlashcards( + apiParams.search, + apiParams.favoritesOnly, + undefined, // difficultyLevel 客戶端處理 + apiParams.partOfSpeech, + apiParams.masteryLevel, + apiParams.sortBy, + apiParams.sortOrder, + 1, // 獲取第一頁 + 1000 // 大數值以獲取所有資料用於客戶端篩選 + ); + + if (result.success && result.data) { + let allFlashcards = result.data.flashcards; + + // 客戶端篩選 (因為後端不支援某些篩選功能) + if (state.filters.difficultyLevel) { + allFlashcards = allFlashcards.filter(card => + (card as any).difficultyLevel === state.filters.difficultyLevel + ); + } + + // 客戶端排序 (確保排序正確) + allFlashcards.sort((a, b) => { + let aValue: any, bValue: any; + + switch (state.sorting.sortBy) { + case 'word': + aValue = a.word.toLowerCase(); + bValue = b.word.toLowerCase(); + break; + case 'createdAt': + aValue = new Date(a.createdAt); + bValue = new Date(b.createdAt); + break; + case 'masteryLevel': + aValue = a.masteryLevel; + bValue = b.masteryLevel; + break; + case 'difficultyLevel': + const levels = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2']; + aValue = levels.indexOf((a as any).difficultyLevel || 'A1'); + bValue = levels.indexOf((b as any).difficultyLevel || 'A1'); + break; + case 'timesReviewed': + aValue = a.timesReviewed; + bValue = b.timesReviewed; + break; + default: + aValue = new Date(a.createdAt); + bValue = new Date(b.createdAt); + } + + if (state.sorting.sortOrder === 'asc') { + return aValue < bValue ? -1 : aValue > bValue ? 1 : 0; + } else { + return aValue > bValue ? -1 : aValue < bValue ? 1 : 0; + } + }); + + const totalFilteredCount = allFlashcards.length; + + // 客戶端分頁處理 + const startIndex = (state.pagination.currentPage - 1) * state.pagination.pageSize; + const endIndex = startIndex + state.pagination.pageSize; + const paginatedFlashcards = allFlashcards.slice(startIndex, endIndex); + + const totalPages = Math.ceil(totalFilteredCount / state.pagination.pageSize); + const currentPage = state.pagination.currentPage; + + setState(prev => ({ + ...prev, + flashcards: paginatedFlashcards, + pagination: { + ...prev.pagination, + totalPages, + totalCount: totalFilteredCount, // 使用篩選後的總數 + hasNext: currentPage < totalPages, + hasPrev: currentPage > 1, + }, + loading: false, + isInitialLoad: false, + lastUpdated: new Date(), + cacheHit: false, // 暫時設為 false,等後端支援 + })); + } else { + setState(prev => ({ + ...prev, + loading: false, + error: result.error || 'Failed to load flashcards', + })); + } + } catch (error) { + setState(prev => ({ + ...prev, + loading: false, + error: error instanceof Error ? error.message : 'Unknown error', + })); + } + }, [ + state.filters.search, + state.filters.difficultyLevel, + state.filters.partOfSpeech, + state.filters.masteryLevel, + state.filters.favoritesOnly, + state.sorting.sortBy, + state.sorting.sortOrder, + state.pagination.currentPage, + state.pagination.pageSize, + activeTab + ]); + + // 防抖搜尋 + const debouncedSearch = useDebounce(executeSearch, 300); + + // Actions + const updateFilters = useCallback((newFilters: Partial) => { + setState(prev => ({ + ...prev, + filters: { ...prev.filters, ...newFilters }, + pagination: { ...prev.pagination, currentPage: 1 }, // 重置頁碼 + })); + }, []); + + const clearFilters = useCallback(() => { + setState(prev => ({ + ...prev, + filters: { + search: '', + difficultyLevel: '', + partOfSpeech: '', + masteryLevel: '', + favoritesOnly: false, + }, + pagination: { ...prev.pagination, currentPage: 1 }, + })); + }, []); + + const resetFilters = useCallback(() => { + setState(prev => ({ + ...prev, + filters: initialState.filters, + sorting: initialState.sorting, + pagination: { ...prev.pagination, currentPage: 1, pageSize: 20 }, + })); + }, []); + + const updateSorting = useCallback((newSorting: Partial) => { + setState(prev => ({ + ...prev, + sorting: { ...prev.sorting, ...newSorting }, + pagination: { ...prev.pagination, currentPage: 1 }, + })); + }, []); + + const toggleSortOrder = useCallback(() => { + setState(prev => ({ + ...prev, + sorting: { + ...prev.sorting, + sortOrder: prev.sorting.sortOrder === 'asc' ? 'desc' : 'asc' + }, + pagination: { ...prev.pagination, currentPage: 1 }, + })); + }, []); + + const goToPage = useCallback((page: number) => { + if (page >= 1 && page <= state.pagination.totalPages) { + setState(prev => ({ + ...prev, + pagination: { ...prev.pagination, currentPage: page }, + })); + } + }, [state.pagination.totalPages]); + + const changePageSize = useCallback((pageSize: number) => { + setState(prev => ({ + ...prev, + pagination: { ...prev.pagination, pageSize, currentPage: 1 }, + })); + }, []); + + const goToNextPage = useCallback(() => { + if (state.pagination.hasNext) { + goToPage(state.pagination.currentPage + 1); + } + }, [state.pagination.hasNext, state.pagination.currentPage, goToPage]); + + const goToPrevPage = useCallback(() => { + if (state.pagination.hasPrev) { + goToPage(state.pagination.currentPage - 1); + } + }, [state.pagination.hasPrev, state.pagination.currentPage, goToPage]); + + const refresh = useCallback(async () => { + await executeSearch(); + }, [executeSearch]); + + const refetch = useCallback(async () => { + setState(prev => ({ ...prev, isInitialLoad: true })); + await executeSearch(); + }, [executeSearch]); + + // 自動執行搜尋 + useEffect(() => { + if (state.filters.search) { + debouncedSearch(); + } else { + executeSearch(); + } + }, [ + state.filters, + state.sorting, + state.pagination.currentPage, + state.pagination.pageSize, + activeTab + ]); + + // 檢查是否有活動篩選 + const hasActiveFilters = useMemo(() => { + return !!( + state.filters.search || + state.filters.difficultyLevel || + state.filters.partOfSpeech || + state.filters.masteryLevel || + state.filters.favoritesOnly + ); + }, [state.filters]); + + // 增強的狀態 + const enhancedState = useMemo(() => ({ + ...state, + hasActiveFilters, + }), [state, hasActiveFilters]); + + return [ + enhancedState, + { + updateFilters, + clearFilters, + resetFilters, + updateSorting, + toggleSortOrder, + goToPage, + changePageSize, + goToNextPage, + goToPrevPage, + refresh, + refetch, + }, + ]; +}; \ No newline at end of file diff --git a/frontend/lib/services/flashcards.ts b/frontend/lib/services/flashcards.ts index 18a4a69..e67c722 100644 --- a/frontend/lib/services/flashcards.ts +++ b/frontend/lib/services/flashcards.ts @@ -57,13 +57,17 @@ class FlashcardsService { return response.json(); } - // 詞卡查詢方法 (支援進階篩選) + // 詞卡查詢方法 (支援進階篩選、排序和分頁) async getFlashcards( search?: string, favoritesOnly: boolean = false, cefrLevel?: string, partOfSpeech?: string, - masteryLevel?: string + masteryLevel?: string, + sortBy?: string, + sortOrder?: 'asc' | 'desc', + page?: number, + limit?: number ): Promise> { try { const params = new URLSearchParams(); @@ -73,6 +77,12 @@ class FlashcardsService { if (partOfSpeech) params.append('partOfSpeech', partOfSpeech); if (masteryLevel) params.append('masteryLevel', masteryLevel); + // 排序和分頁參數 + if (sortBy) params.append('sortBy', sortBy); + if (sortOrder) params.append('sortOrder', sortOrder); + if (page) params.append('page', page.toString()); + if (limit) params.append('limit', limit.toString()); + const queryString = params.toString(); const endpoint = `/flashcards${queryString ? `?${queryString}` : ''}`;