620 lines
16 KiB
Markdown
620 lines
16 KiB
Markdown
# 🎨 前端架構設計
|
|
|
|
## 🎯 設計原則
|
|
|
|
### 核心理念
|
|
- **狀態驅動** - 單一真實來源 (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* |