diff --git a/frontend/hooks/useFlashcardSearch.ts b/frontend/hooks/useFlashcardSearch.ts index 476d444..9397f55 100644 --- a/frontend/hooks/useFlashcardSearch.ts +++ b/frontend/hooks/useFlashcardSearch.ts @@ -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; refetch: () => Promise; + clearCache: () => void; } // 初始狀態 @@ -101,117 +114,175 @@ const initialState: SearchState = { export const useFlashcardSearch = (activeTab: 'all-cards' | 'favorites' = 'all-cards'): [SearchState, SearchActions] => { const [state, setState] = useState(initialState); - // 搜尋邏輯 + // 資料快取 + const cacheRef = useRef>(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, }, ]; }; \ No newline at end of file