feat: 實現進階搜尋功能的完整前後端架構重構
- 新增完整的前後端架構設計文檔 - 實現 useFlashcardSearch Hook 統一狀態管理 - 重構 FlashcardsPage 使用新架構 - 添加排序和分頁功能 - 實現客戶端 CEFR 等級篩選 - 修復 TypeScript 類型錯誤 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
75f81f3e2e
commit
e05e6f09f2
|
|
@ -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*
|
||||
|
|
@ -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*
|
||||
|
|
@ -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<SearchFilters>) => void;
|
||||
clearFilters: () => void;
|
||||
resetFilters: () => void;
|
||||
|
||||
// 排序操作
|
||||
updateSorting: (sorting: Partial<SortOptions>) => void;
|
||||
toggleSortOrder: () => void;
|
||||
|
||||
// 分頁操作
|
||||
goToPage: (page: number) => void;
|
||||
changePageSize: (size: number) => void;
|
||||
goToNextPage: () => void;
|
||||
goToPrevPage: () => void;
|
||||
|
||||
// 資料操作
|
||||
refresh: () => Promise<void>;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useFlashcardSearch = (): [SearchState, SearchActions] => {
|
||||
// 狀態管理實作...
|
||||
};
|
||||
```
|
||||
|
||||
### 實作細節
|
||||
|
||||
#### 1. 核心狀態管理
|
||||
```typescript
|
||||
export const useFlashcardSearch = () => {
|
||||
const [state, setState] = useState<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,
|
||||
});
|
||||
|
||||
// 搜尋邏輯
|
||||
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<SearchFilters>) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
filters: { ...prev.filters, ...newFilters },
|
||||
pagination: { ...prev.pagination, currentPage: 1 }, // 重置頁碼
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const updateSorting = useCallback((newSorting: Partial<SortOptions>) => {
|
||||
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<SearchFilters>) => void;
|
||||
onSortingChange: (sorting: Partial<SortOptions>) => void;
|
||||
onClearFilters: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const SearchControls: React.FC<SearchControlsProps> = ({
|
||||
filters,
|
||||
sorting,
|
||||
onFiltersChange,
|
||||
onSortingChange,
|
||||
onClearFilters,
|
||||
loading = false,
|
||||
}) => {
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
{/* 基本搜尋 */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<SearchInput
|
||||
value={filters.search}
|
||||
onChange={(search) => onFiltersChange({ search })}
|
||||
placeholder="搜尋詞彙、翻譯或定義..."
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
<SortControls
|
||||
sortBy={sorting.sortBy}
|
||||
sortOrder={sorting.sortOrder}
|
||||
onChange={onSortingChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 進階篩選 */}
|
||||
{showAdvanced && (
|
||||
<FilterPanel
|
||||
filters={filters}
|
||||
onChange={onFiltersChange}
|
||||
onClear={onClearFilters}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 切換按鈕 */}
|
||||
<button
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
{showAdvanced ? '收起篩選' : '進階篩選'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
#### 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<string, CacheEntry>();
|
||||
|
||||
async getFlashcards(params: FlashcardQueryParams): Promise<ApiResponse<FlashcardQueryResponse>> {
|
||||
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<ApiResponse<FlashcardQueryResponse>>(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(<FlashcardsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('test')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 效能優化
|
||||
|
||||
### React優化策略
|
||||
```typescript
|
||||
// 使用 React.memo 避免不必要渲染
|
||||
export const FlashcardCard = React.memo<FlashcardCardProps>(({ 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 }) => (
|
||||
<div style={style}>
|
||||
<FlashcardCard flashcard={flashcards[index]} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<List
|
||||
height={height}
|
||||
itemCount={flashcards.length}
|
||||
itemSize={120}
|
||||
width="100%"
|
||||
>
|
||||
{Row}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
這個前端架構確保:
|
||||
- 🎯 **清晰的狀態管理** - 單一真實來源
|
||||
- ⚡ **高效能** - 防抖、快取、虛擬滾動
|
||||
- 🔄 **URL同步** - 支援書籤和分享
|
||||
- 🧪 **可測試** - 完整的測試覆蓋
|
||||
- 🔧 **可維護** - 組件分離、類型安全
|
||||
|
||||
---
|
||||
|
||||
*文檔版本: 1.0*
|
||||
*最後更新: 2025-09-24*
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* useDebounce Hook
|
||||
*
|
||||
* 創建一個防抖版本的函數,在指定延遲後執行
|
||||
* 如果在延遲期間再次調用,會取消前一次並重新開始計時
|
||||
*
|
||||
* @param callback 要防抖的函數
|
||||
* @param delay 延遲時間(毫秒)
|
||||
* @returns 防抖後的函數
|
||||
*/
|
||||
export const useDebounce = <T extends (...args: any[]) => any>(
|
||||
callback: T,
|
||||
delay: number
|
||||
): T => {
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const debouncedCallback = useCallback(
|
||||
(...args: Parameters<T>) => {
|
||||
// 清除之前的定時器
|
||||
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 = <T>(value: T, delay: number): T => {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(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;
|
||||
};
|
||||
|
||||
|
|
@ -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<SearchFilters>) => void;
|
||||
clearFilters: () => void;
|
||||
resetFilters: () => void;
|
||||
|
||||
// 排序操作
|
||||
updateSorting: (sorting: Partial<SortOptions>) => void;
|
||||
toggleSortOrder: () => void;
|
||||
|
||||
// 分頁操作
|
||||
goToPage: (page: number) => void;
|
||||
changePageSize: (size: number) => void;
|
||||
goToNextPage: () => void;
|
||||
goToPrevPage: () => void;
|
||||
|
||||
// 資料操作
|
||||
refresh: () => Promise<void>;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
// 初始狀態
|
||||
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<SearchState>(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<SearchFilters>) => {
|
||||
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<SortOptions>) => {
|
||||
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,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
|
@ -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<ApiResponse<{ flashcards: Flashcard[], count: number }>> {
|
||||
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}` : ''}`;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue