dramaling-vocab-learning/frontend/hooks/flashcards/useFlashcardSearch.ts

468 lines
13 KiB
TypeScript

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;
cefr: 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>;
clearCache: () => 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: '',
cefr: '',
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 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 apiFilters = {
search: state.filters.search || undefined,
favoritesOnly: activeTab === 'favorites' || state.filters.favoritesOnly,
partOfSpeech: state.filters.partOfSpeech || undefined,
masteryLevel: state.filters.masteryLevel || undefined,
};
// 檢查快取
const cachedData = getCachedData(apiFilters);
let allFlashcards: Flashcard[];
let cacheHit = false;
if (cachedData) {
// 使用快取資料
allFlashcards = cachedData;
cacheHit = true;
console.log('🎯 使用快取資料:', allFlashcards.length, '個詞卡');
} else {
// API 調用 (只發送後端支援的參數)
const result = await flashcardsService.getFlashcards(
apiFilters.search,
apiFilters.favoritesOnly,
undefined, // cefr 客戶端處理
apiFilters.partOfSpeech,
apiFilters.masteryLevel,
undefined, // sortBy 客戶端處理
undefined, // sortOrder 客戶端處理
1, // 獲取第一頁
1000 // 大數值以獲取所有資料
);
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.cefr) {
allFlashcards = allFlashcards.filter(card =>
(card as any).cefr === state.filters.cefr
);
}
// 客戶端排序 (確保排序正確)
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 'cefr':
const levels = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2'];
aValue = levels.indexOf((a as any).cefr || 'A1');
bValue = levels.indexOf((b as any).cefr || '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,
}));
} catch (error) {
setState(prev => ({
...prev,
loading: false,
error: error instanceof Error ? error.message : 'Unknown error',
}));
}
}, [
state.filters.search,
state.filters.cefr,
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: '',
cefr: '',
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 () => {
// 清除快取並重新載入
cacheRef.current.clear();
setState(prev => ({ ...prev, isInitialLoad: true }));
await executeSearch();
}, [executeSearch]);
// 清除快取的公用方法
const clearCache = useCallback(() => {
cacheRef.current.clear();
}, []);
// 智能觸發搜尋 (區分需要API調用的變更)
useEffect(() => {
if (state.filters.search) {
debouncedSearch();
} else {
executeSearch();
}
}, [
// 影響後端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.cefr, // CEFR篩選
state.sorting.sortBy,
state.sorting.sortOrder,
state.pagination.currentPage,
state.pagination.pageSize,
]);
// 檢查是否有活動篩選
const hasActiveFilters = useMemo(() => {
return !!(
state.filters.search ||
state.filters.cefr ||
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,
clearCache,
},
];
};