feat: 實現智能快取策略優化CEFR篩選功能
- 添加資料快取機制,5分鐘TTL避免重複API調用 - 分離API篩選與客戶端篩選邏輯 - CEFR等級篩選使用快取資料,瞬間響應 - 智能觸發邏輯,只在必要時重新呼叫API - 客戶端排序和分頁,提升用戶體驗 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
e05e6f09f2
commit
0549b1c972
|
|
@ -1,7 +1,19 @@
|
|||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { flashcardsService, type Flashcard } from '@/lib/services/flashcards';
|
||||
import { useDebounce } from './useDebounce';
|
||||
|
||||
// 快取介面定義
|
||||
interface CacheEntry {
|
||||
data: Flashcard[];
|
||||
timestamp: Date;
|
||||
filters: {
|
||||
search?: string;
|
||||
favoritesOnly: boolean;
|
||||
partOfSpeech?: string;
|
||||
masteryLevel?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// 類型定義
|
||||
export interface SearchFilters {
|
||||
search: string;
|
||||
|
|
@ -67,6 +79,7 @@ export interface SearchActions {
|
|||
// 資料操作
|
||||
refresh: () => Promise<void>;
|
||||
refetch: () => Promise<void>;
|
||||
clearCache: () => void;
|
||||
}
|
||||
|
||||
// 初始狀態
|
||||
|
|
@ -101,117 +114,175 @@ const initialState: SearchState = {
|
|||
export const useFlashcardSearch = (activeTab: 'all-cards' | 'favorites' = 'all-cards'): [SearchState, SearchActions] => {
|
||||
const [state, setState] = useState<SearchState>(initialState);
|
||||
|
||||
// 搜尋邏輯
|
||||
// 資料快取
|
||||
const cacheRef = useRef<Map<string, CacheEntry>>(new Map());
|
||||
|
||||
// 快取輔助函數
|
||||
const generateCacheKey = useCallback((filters: any) => {
|
||||
return JSON.stringify({
|
||||
search: filters.search || '',
|
||||
favoritesOnly: filters.favoritesOnly || false,
|
||||
partOfSpeech: filters.partOfSpeech || '',
|
||||
masteryLevel: filters.masteryLevel || '',
|
||||
activeTab
|
||||
});
|
||||
}, [activeTab]);
|
||||
|
||||
const getCachedData = useCallback((filters: any): Flashcard[] | null => {
|
||||
const cacheKey = generateCacheKey(filters);
|
||||
const cached = cacheRef.current.get(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
// 檢查快取是否過期 (5分鐘)
|
||||
const isExpired = new Date().getTime() - cached.timestamp.getTime() > 300000;
|
||||
if (!isExpired) {
|
||||
return cached.data;
|
||||
} else {
|
||||
cacheRef.current.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [generateCacheKey]);
|
||||
|
||||
const setCachedData = useCallback((filters: any, data: Flashcard[]) => {
|
||||
const cacheKey = generateCacheKey(filters);
|
||||
cacheRef.current.set(cacheKey, {
|
||||
data,
|
||||
timestamp: new Date(),
|
||||
filters: {
|
||||
search: filters.search,
|
||||
favoritesOnly: filters.favoritesOnly,
|
||||
partOfSpeech: filters.partOfSpeech,
|
||||
masteryLevel: filters.masteryLevel,
|
||||
}
|
||||
});
|
||||
}, [generateCacheKey]);
|
||||
|
||||
// 搜尋邏輯 (智能快取版本)
|
||||
const executeSearch = useCallback(async () => {
|
||||
setState(prev => ({ ...prev, loading: true, error: null }));
|
||||
|
||||
try {
|
||||
// 構建 API 參數
|
||||
const apiParams = {
|
||||
// 構建 API 參數 (只包含後端支援的篩選)
|
||||
const apiFilters = {
|
||||
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 // 大數值以獲取所有資料用於客戶端篩選
|
||||
);
|
||||
// 檢查快取
|
||||
const cachedData = getCachedData(apiFilters);
|
||||
let allFlashcards: Flashcard[];
|
||||
let cacheHit = false;
|
||||
|
||||
if (result.success && result.data) {
|
||||
let allFlashcards = result.data.flashcards;
|
||||
if (cachedData) {
|
||||
// 使用快取資料
|
||||
allFlashcards = cachedData;
|
||||
cacheHit = true;
|
||||
console.log('🎯 使用快取資料:', allFlashcards.length, '個詞卡');
|
||||
} else {
|
||||
// API 調用 (只發送後端支援的參數)
|
||||
const result = await flashcardsService.getFlashcards(
|
||||
apiFilters.search,
|
||||
apiFilters.favoritesOnly,
|
||||
undefined, // difficultyLevel 客戶端處理
|
||||
apiFilters.partOfSpeech,
|
||||
apiFilters.masteryLevel,
|
||||
undefined, // sortBy 客戶端處理
|
||||
undefined, // sortOrder 客戶端處理
|
||||
1, // 獲取第一頁
|
||||
1000 // 大數值以獲取所有資料
|
||||
);
|
||||
|
||||
// 客戶端篩選 (因為後端不支援某些篩選功能)
|
||||
if (state.filters.difficultyLevel) {
|
||||
allFlashcards = allFlashcards.filter(card =>
|
||||
(card as any).difficultyLevel === state.filters.difficultyLevel
|
||||
);
|
||||
if (result.success && result.data) {
|
||||
allFlashcards = result.data.flashcards;
|
||||
// 快取資料
|
||||
setCachedData(apiFilters, allFlashcards);
|
||||
console.log('📡 API載入資料:', allFlashcards.length, '個詞卡');
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: result.error || 'Failed to load flashcards',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 統一處理客戶端篩選和排序 (無論資料來自快取或API)
|
||||
|
||||
// 客戶端篩選 (因為後端不支援某些篩選功能)
|
||||
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);
|
||||
}
|
||||
|
||||
// 客戶端排序 (確保排序正確)
|
||||
allFlashcards.sort((a, b) => {
|
||||
let aValue: any, bValue: any;
|
||||
if (state.sorting.sortOrder === 'asc') {
|
||||
return aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
|
||||
} else {
|
||||
return aValue > bValue ? -1 : aValue < bValue ? 1 : 0;
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
const totalFilteredCount = allFlashcards.length;
|
||||
|
||||
if (state.sorting.sortOrder === 'asc') {
|
||||
return aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
|
||||
} else {
|
||||
return aValue > bValue ? -1 : aValue < bValue ? 1 : 0;
|
||||
}
|
||||
});
|
||||
// 客戶端分頁處理
|
||||
const startIndex = (state.pagination.currentPage - 1) * state.pagination.pageSize;
|
||||
const endIndex = startIndex + state.pagination.pageSize;
|
||||
const paginatedFlashcards = allFlashcards.slice(startIndex, endIndex);
|
||||
|
||||
const totalFilteredCount = allFlashcards.length;
|
||||
const totalPages = Math.ceil(totalFilteredCount / state.pagination.pageSize);
|
||||
const currentPage = state.pagination.currentPage;
|
||||
|
||||
// 客戶端分頁處理
|
||||
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',
|
||||
}));
|
||||
}
|
||||
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,
|
||||
}));
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
|
|
@ -319,11 +390,18 @@ export const useFlashcardSearch = (activeTab: 'all-cards' | 'favorites' = 'all-c
|
|||
}, [executeSearch]);
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
// 清除快取並重新載入
|
||||
cacheRef.current.clear();
|
||||
setState(prev => ({ ...prev, isInitialLoad: true }));
|
||||
await executeSearch();
|
||||
}, [executeSearch]);
|
||||
|
||||
// 自動執行搜尋
|
||||
// 清除快取的公用方法
|
||||
const clearCache = useCallback(() => {
|
||||
cacheRef.current.clear();
|
||||
}, []);
|
||||
|
||||
// 智能觸發搜尋 (區分需要API調用的變更)
|
||||
useEffect(() => {
|
||||
if (state.filters.search) {
|
||||
debouncedSearch();
|
||||
|
|
@ -331,11 +409,26 @@ export const useFlashcardSearch = (activeTab: 'all-cards' | 'favorites' = 'all-c
|
|||
executeSearch();
|
||||
}
|
||||
}, [
|
||||
state.filters,
|
||||
state.sorting,
|
||||
// 影響後端API的條件
|
||||
state.filters.search,
|
||||
state.filters.favoritesOnly,
|
||||
state.filters.partOfSpeech,
|
||||
state.filters.masteryLevel,
|
||||
activeTab
|
||||
]);
|
||||
|
||||
// 僅客戶端處理的條件變更 (不需重新API調用)
|
||||
useEffect(() => {
|
||||
// 如果資料已載入且只是客戶端篩選/排序/分頁變更,直接處理
|
||||
if (state.flashcards.length > 0 || !state.isInitialLoad) {
|
||||
executeSearch();
|
||||
}
|
||||
}, [
|
||||
state.filters.difficultyLevel, // CEFR篩選
|
||||
state.sorting.sortBy,
|
||||
state.sorting.sortOrder,
|
||||
state.pagination.currentPage,
|
||||
state.pagination.pageSize,
|
||||
activeTab
|
||||
]);
|
||||
|
||||
// 檢查是否有活動篩選
|
||||
|
|
@ -369,6 +462,7 @@ export const useFlashcardSearch = (activeTab: 'all-cards' | 'favorites' = 'all-c
|
|||
goToPrevPage,
|
||||
refresh,
|
||||
refetch,
|
||||
clearCache,
|
||||
},
|
||||
];
|
||||
};
|
||||
Loading…
Reference in New Issue