693 lines
18 KiB
Markdown
693 lines
18 KiB
Markdown
# DramaLing 前端架構詳細說明
|
||
|
||
## 1. 技術棧概覽
|
||
|
||
### 1.1 核心技術
|
||
- **框架**: Next.js 15 (App Router)
|
||
- **語言**: TypeScript 5.x
|
||
- **樣式框架**: Tailwind CSS 3.x
|
||
- **UI 組件**: React 18.x + 自定義組件
|
||
- **狀態管理**: React useState/useEffect hooks
|
||
- **API 通信**: Fetch API + 自定義 Service 層
|
||
|
||
### 1.2 開發工具
|
||
- **套件管理**: npm
|
||
- **建置工具**: Next.js 內建 (Webpack + SWC)
|
||
- **型別檢查**: TypeScript Compiler
|
||
- **代碼格式化**: Prettier (如果配置)
|
||
- **代碼檢查**: ESLint (如果配置)
|
||
|
||
## 2. 專案結構
|
||
|
||
### 2.1 目錄架構
|
||
|
||
```
|
||
frontend/
|
||
├── app/ # Next.js App Router 頁面
|
||
│ ├── flashcards/ # 詞卡管理頁面
|
||
│ │ ├── page.tsx # 詞卡列表頁面
|
||
│ │ └── [id]/ # 動態詞卡詳細頁面
|
||
│ │ └── page.tsx
|
||
│ ├── generate/ # AI 生成詞卡頁面
|
||
│ │ └── page.tsx
|
||
│ ├── settings/ # 設定頁面
|
||
│ │ └── page.tsx
|
||
│ ├── layout.tsx # 根佈局
|
||
│ ├── page.tsx # 首頁
|
||
│ └── globals.css # 全域樣式
|
||
├── components/ # 可重用組件
|
||
│ ├── ClickableTextV2.tsx # 可點擊文字組件
|
||
│ ├── FlashcardForm.tsx # 詞卡表單組件
|
||
│ ├── Navigation.tsx # 導航組件
|
||
│ ├── ProtectedRoute.tsx # 路由保護組件
|
||
│ └── AudioPlayer.tsx # 音頻播放組件
|
||
├── lib/ # 工具函數和服務
|
||
│ ├── services/ # API 服務層
|
||
│ │ ├── flashcards.ts # 詞卡 API 服務
|
||
│ │ └── auth.ts # 認證 API 服務
|
||
│ └── utils/ # 工具函數
|
||
├── public/ # 靜態資源
|
||
│ └── images/ # 圖片資源
|
||
├── package.json # 專案配置
|
||
├── tailwind.config.js # Tailwind 配置
|
||
├── tsconfig.json # TypeScript 配置
|
||
└── next.config.js # Next.js 配置
|
||
```
|
||
|
||
## 3. 頁面架構設計
|
||
|
||
### 3.1 詞卡管理頁面 (app/flashcards/page.tsx)
|
||
|
||
#### 組件層級結構
|
||
```
|
||
FlashcardsPage (Protected Route Wrapper)
|
||
└── FlashcardsContent (Main Logic Component)
|
||
├── Navigation (Top Navigation Bar)
|
||
├── Page Header (Title + Action Buttons)
|
||
├── Tab System (All Cards / Favorites)
|
||
├── Search & Filter Section
|
||
│ ├── Main Search Input
|
||
│ ├── Advanced Filters (Collapsible)
|
||
│ └── Quick Filter Buttons
|
||
├── Flashcard List Display
|
||
│ └── Flashcard Card Components (Repeated)
|
||
└── FlashcardForm Modal (When Editing)
|
||
```
|
||
|
||
#### 狀態管理架構
|
||
```typescript
|
||
// 主要狀態
|
||
const [activeTab, setActiveTab] = useState<'all-cards' | 'favorites'>('all-cards')
|
||
const [searchTerm, setSearchTerm] = useState<string>('')
|
||
const [searchFilters, setSearchFilters] = useState<SearchFilters>({
|
||
cefrLevel: '',
|
||
partOfSpeech: '',
|
||
masteryLevel: '',
|
||
onlyFavorites: false
|
||
})
|
||
|
||
// 資料狀態
|
||
const [flashcards, setFlashcards] = useState<Flashcard[]>([])
|
||
const [loading, setLoading] = useState<boolean>(true)
|
||
const [error, setError] = useState<string | null>(null)
|
||
|
||
// 表單狀態
|
||
const [showForm, setShowForm] = useState<boolean>(false)
|
||
const [editingCard, setEditingCard] = useState<Flashcard | null>(null)
|
||
```
|
||
|
||
### 3.2 AI 分析頁面 (app/generate/page.tsx)
|
||
|
||
#### 組件結構
|
||
```
|
||
GeneratePage (Protected Route Wrapper)
|
||
└── GenerateContent (Main Logic Component)
|
||
├── Navigation
|
||
├── Input Section (Conditional Rendering)
|
||
│ ├── Text Input Area
|
||
│ ├── Character Counter
|
||
│ └── Analysis Button
|
||
└── Analysis Results Section (Conditional Rendering)
|
||
├── Star Explanation Banner
|
||
├── Grammar Correction Panel (If Needed)
|
||
├── Main Sentence Display
|
||
│ ├── Vocabulary Statistics Cards
|
||
│ ├── ClickableTextV2 Component
|
||
│ ├── Translation Section
|
||
│ └── Idioms Display Section
|
||
└── Action Buttons
|
||
```
|
||
|
||
#### 分析結果資料流
|
||
```typescript
|
||
// AI 分析請求
|
||
handleAnalyzeSentence()
|
||
→ fetch('/api/ai/analyze-sentence')
|
||
→ setSentenceAnalysis(apiData)
|
||
→ setShowAnalysisView(true)
|
||
|
||
// 詞卡保存流程
|
||
handleSaveWord()
|
||
→ flashcardsService.createFlashcard()
|
||
→ alert(success/failure message)
|
||
```
|
||
|
||
## 4. 組件設計模式
|
||
|
||
### 4.1 可重用組件設計
|
||
|
||
#### ClickableTextV2 組件
|
||
```typescript
|
||
interface ClickableTextProps {
|
||
text: string; // 顯示文字
|
||
analysis?: Record<string, WordAnalysis>; // 詞彙分析資料
|
||
onWordClick?: (word: string, analysis: WordAnalysis) => void;
|
||
onSaveWord?: (word: string, analysis: WordAnalysis) => Promise<{success: boolean}>;
|
||
remainingUsage?: number; // 剩餘使用次數
|
||
showIdiomsInline?: boolean; // 是否內嵌顯示慣用語
|
||
}
|
||
|
||
// 設計特色:
|
||
// - 詞彙點擊互動
|
||
// - CEFR 等級顏色編碼
|
||
// - 星星標記 (高頻詞彙)
|
||
// - 彈出式詞彙詳情
|
||
// - Portal 渲染優化
|
||
```
|
||
|
||
#### FlashcardForm 組件
|
||
```typescript
|
||
interface FlashcardFormProps {
|
||
initialData?: Partial<Flashcard>; // 編輯時的初始資料
|
||
isEdit?: boolean; // 是否為編輯模式
|
||
onSuccess: () => void; // 成功回調
|
||
onCancel: () => void; // 取消回調
|
||
}
|
||
|
||
// 表單欄位:
|
||
// - word (必填)
|
||
// - translation (必填)
|
||
// - definition (必填)
|
||
// - pronunciation (選填)
|
||
// - partOfSpeech (選填,下拉選單)
|
||
// - example (必填)
|
||
// - exampleTranslation (選填)
|
||
```
|
||
|
||
### 4.2 路由保護模式
|
||
|
||
#### ProtectedRoute 組件
|
||
```typescript
|
||
// 用途:保護需要認證的頁面
|
||
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||
// 檢查認證狀態
|
||
// 未登入時導向登入頁面
|
||
// 已登入時顯示子組件
|
||
return (
|
||
<div>
|
||
{/* 認證檢查邏輯 */}
|
||
{children}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 使用方式:
|
||
<ProtectedRoute>
|
||
<FlashcardsContent />
|
||
</ProtectedRoute>
|
||
```
|
||
|
||
## 5. API 服務層設計
|
||
|
||
### 5.1 服務類別架構
|
||
|
||
#### FlashcardsService 類別
|
||
```typescript
|
||
class FlashcardsService {
|
||
private readonly baseURL = `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5008'}/api`;
|
||
|
||
// 統一的請求處理方法
|
||
private async makeRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T>
|
||
|
||
// CRUD 操作方法
|
||
async getFlashcards(search?: string, favoritesOnly?: boolean): Promise<ApiResponse<{flashcards: Flashcard[], count: number}>>
|
||
async getFlashcard(id: string): Promise<ApiResponse<Flashcard>>
|
||
async createFlashcard(data: CreateFlashcardRequest): Promise<ApiResponse<Flashcard>>
|
||
async updateFlashcard(id: string, data: CreateFlashcardRequest): Promise<ApiResponse<Flashcard>>
|
||
async deleteFlashcard(id: string): Promise<ApiResponse<void>>
|
||
async toggleFavorite(id: string): Promise<ApiResponse<void>>
|
||
}
|
||
|
||
// 單例模式匯出
|
||
export const flashcardsService = new FlashcardsService();
|
||
```
|
||
|
||
### 5.2 型別定義標準化
|
||
|
||
#### 核心型別定義
|
||
```typescript
|
||
// 詞卡介面定義
|
||
export interface Flashcard {
|
||
id: string;
|
||
word: string;
|
||
translation: string;
|
||
definition: string;
|
||
partOfSpeech: string;
|
||
pronunciation: string;
|
||
example: string;
|
||
exampleTranslation?: string;
|
||
masteryLevel: number;
|
||
timesReviewed: number;
|
||
isFavorite: boolean;
|
||
nextReviewDate: string;
|
||
difficultyLevel: string;
|
||
createdAt: string;
|
||
updatedAt?: string;
|
||
}
|
||
|
||
// API 回應格式
|
||
export interface ApiResponse<T> {
|
||
success: boolean;
|
||
data?: T;
|
||
error?: string;
|
||
message?: string;
|
||
}
|
||
|
||
// 創建請求格式
|
||
export interface CreateFlashcardRequest {
|
||
word: string;
|
||
translation: string;
|
||
definition: string;
|
||
pronunciation: string;
|
||
partOfSpeech: string;
|
||
example: string;
|
||
exampleTranslation?: string;
|
||
}
|
||
```
|
||
|
||
## 6. 樣式系統架構
|
||
|
||
### 6.1 Tailwind CSS 配置
|
||
|
||
#### 主要設計系統
|
||
```javascript
|
||
// tailwind.config.js
|
||
module.exports = {
|
||
content: ['./app/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}'],
|
||
theme: {
|
||
extend: {
|
||
colors: {
|
||
primary: {
|
||
DEFAULT: '#3B82F6', // 主色調藍色
|
||
hover: '#2563EB' // 懸停狀態
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
#### CEFR 等級顏色系統
|
||
```typescript
|
||
const getCEFRColor = (level: string) => {
|
||
switch (level) {
|
||
case 'A1': return 'bg-green-100 text-green-700 border-green-200'
|
||
case 'A2': return 'bg-blue-100 text-blue-700 border-blue-200'
|
||
case 'B1': return 'bg-yellow-100 text-yellow-700 border-yellow-200'
|
||
case 'B2': return 'bg-orange-100 text-orange-700 border-orange-200'
|
||
case 'C1': return 'bg-red-100 text-red-700 border-red-200'
|
||
case 'C2': return 'bg-purple-100 text-purple-700 border-purple-200'
|
||
default: return 'bg-gray-100 text-gray-700 border-gray-200'
|
||
}
|
||
}
|
||
```
|
||
|
||
### 6.2 響應式設計模式
|
||
|
||
#### 斷點策略
|
||
```css
|
||
/* 手機版 */
|
||
@media (max-width: 767px) {
|
||
.flashcard-grid { grid-template-columns: 1fr; }
|
||
.search-filters { flex-direction: column; }
|
||
}
|
||
|
||
/* 平板版 */
|
||
@media (min-width: 768px) and (max-width: 1023px) {
|
||
.flashcard-grid { grid-template-columns: repeat(2, 1fr); }
|
||
}
|
||
|
||
/* 桌面版 */
|
||
@media (min-width: 1024px) {
|
||
.flashcard-grid { grid-template-columns: repeat(3, 1fr); }
|
||
}
|
||
```
|
||
|
||
## 7. 效能優化策略
|
||
|
||
### 7.1 React 效能優化
|
||
|
||
#### useMemo 和 useCallback 使用
|
||
```typescript
|
||
// 快取詞彙統計計算
|
||
const vocabularyStats = useMemo(() => {
|
||
if (!sentenceAnalysis?.vocabularyAnalysis) return defaultStats;
|
||
|
||
// 複雜計算邏輯
|
||
return calculateStats(sentenceAnalysis.vocabularyAnalysis);
|
||
}, [sentenceAnalysis])
|
||
|
||
// 快取事件處理函數
|
||
const handleWordClick = useCallback(async (word: string, event: React.MouseEvent) => {
|
||
// 事件處理邏輯
|
||
}, [findWordAnalysis, onWordClick, calculatePopupPosition])
|
||
```
|
||
|
||
#### 組件懶載入
|
||
```typescript
|
||
// 動態導入大型組件
|
||
const HeavyComponent = dynamic(() => import('./HeavyComponent'), {
|
||
loading: () => <div>載入中...</div>,
|
||
ssr: false
|
||
})
|
||
```
|
||
|
||
### 7.2 資料載入優化
|
||
|
||
#### 條件式資料載入
|
||
```typescript
|
||
useEffect(() => {
|
||
// 只在需要時載入資料
|
||
if (activeTab === 'favorites') {
|
||
loadFavoriteFlashcards();
|
||
} else {
|
||
loadAllFlashcards();
|
||
}
|
||
}, [activeTab])
|
||
```
|
||
|
||
#### 錯誤邊界處理
|
||
```typescript
|
||
// API 服務層錯誤處理
|
||
private async makeRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||
try {
|
||
const response = await fetch(`${this.baseURL}${endpoint}`, {
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
...options.headers,
|
||
},
|
||
...options,
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errorData = await response.json().catch(() => ({ error: 'Network error' }));
|
||
throw new Error(errorData.error || `HTTP ${response.status}`);
|
||
}
|
||
|
||
return response.json();
|
||
} catch (error) {
|
||
console.error('API request failed:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
```
|
||
|
||
## 8. 狀態管理模式
|
||
|
||
### 8.1 本地狀態管理
|
||
|
||
#### 頁面級狀態
|
||
```typescript
|
||
// 每個頁面管理自己的狀態
|
||
function FlashcardsContent() {
|
||
// UI 狀態
|
||
const [activeTab, setActiveTab] = useState('all-cards')
|
||
const [showForm, setShowForm] = useState(false)
|
||
|
||
// 資料狀態
|
||
const [flashcards, setFlashcards] = useState<Flashcard[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
|
||
// 搜尋狀態
|
||
const [searchTerm, setSearchTerm] = useState('')
|
||
const [searchFilters, setSearchFilters] = useState(defaultFilters)
|
||
}
|
||
```
|
||
|
||
#### 跨組件狀態同步
|
||
```typescript
|
||
// 通過 props 和 callback 實現父子組件通信
|
||
<FlashcardForm
|
||
initialData={editingCard}
|
||
isEdit={!!editingCard}
|
||
onSuccess={handleFormSuccess} // 成功後重新載入資料
|
||
onCancel={() => setShowForm(false)}
|
||
/>
|
||
```
|
||
|
||
### 8.2 持久化狀態
|
||
|
||
#### localStorage 使用
|
||
```typescript
|
||
// 用戶偏好設定
|
||
const userLevel = localStorage.getItem('userEnglishLevel') || 'A2'
|
||
|
||
// 搜尋歷史 (未來功能)
|
||
const searchHistory = JSON.parse(localStorage.getItem('searchHistory') || '[]')
|
||
```
|
||
|
||
## 9. 用戶體驗設計
|
||
|
||
### 9.1 載入狀態處理
|
||
|
||
#### 統一載入狀態
|
||
```typescript
|
||
if (loading) {
|
||
return (
|
||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||
<div className="text-lg">載入中...</div>
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
#### 按鈕載入狀態
|
||
```typescript
|
||
<button
|
||
disabled={loading}
|
||
className="disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
{loading ? '載入中...' : '提交'}
|
||
</button>
|
||
```
|
||
|
||
### 9.2 錯誤狀態處理
|
||
|
||
#### 頁面級錯誤
|
||
```typescript
|
||
if (error) {
|
||
return (
|
||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||
<div className="text-red-600">{error}</div>
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
#### 表單錯誤
|
||
```typescript
|
||
{error && (
|
||
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-lg">
|
||
{error}
|
||
</div>
|
||
)}
|
||
```
|
||
|
||
### 9.3 空狀態設計
|
||
|
||
#### 友善的空狀態
|
||
```typescript
|
||
{filteredCards.length === 0 ? (
|
||
<div className="text-center py-12">
|
||
<p className="text-gray-500 mb-4">沒有找到詞卡</p>
|
||
<Link href="/generate" className="bg-primary text-white px-4 py-2 rounded-lg">
|
||
創建新詞卡
|
||
</Link>
|
||
</div>
|
||
) : (
|
||
// 詞卡列表
|
||
)}
|
||
```
|
||
|
||
## 10. 互動設計模式
|
||
|
||
### 10.1 搜尋互動
|
||
|
||
#### 即時搜尋
|
||
```typescript
|
||
// 輸入時即時過濾,無防抖延遲
|
||
const filteredCards = allCards.filter(card => {
|
||
if (searchTerm) {
|
||
const searchLower = searchTerm.toLowerCase()
|
||
return card.word?.toLowerCase().includes(searchLower) ||
|
||
card.translation?.toLowerCase().includes(searchLower) ||
|
||
card.definition?.toLowerCase().includes(searchLower)
|
||
}
|
||
return true
|
||
})
|
||
```
|
||
|
||
#### 搜尋結果高亮
|
||
```typescript
|
||
const highlightSearchTerm = (text: string, searchTerm: string) => {
|
||
if (!searchTerm || !text) return text
|
||
|
||
const regex = new RegExp(`(${searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi')
|
||
const parts = text.split(regex)
|
||
|
||
return parts.map((part, index) =>
|
||
regex.test(part) ? (
|
||
<mark key={index} className="bg-yellow-200 text-yellow-900 px-1 rounded">
|
||
{part}
|
||
</mark>
|
||
) : part
|
||
)
|
||
}
|
||
```
|
||
|
||
### 10.2 彈窗互動設計
|
||
|
||
#### Portal 渲染模式
|
||
```typescript
|
||
import { createPortal } from 'react-dom'
|
||
|
||
const VocabPopup = () => {
|
||
if (!selectedWord || !mounted) return null
|
||
|
||
return createPortal(
|
||
<>
|
||
{/* 遮罩層 */}
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 z-40" onClick={closePopup} />
|
||
|
||
{/* 彈窗內容 */}
|
||
<div className="fixed z-50 bg-white rounded-xl shadow-lg" style={{...}}>
|
||
{/* 詞彙詳細資訊 */}
|
||
</div>
|
||
</>,
|
||
document.body
|
||
)
|
||
}
|
||
```
|
||
|
||
### 10.3 鍵盤操作支援
|
||
|
||
#### ESC 鍵清除搜尋
|
||
```typescript
|
||
<input
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Escape') {
|
||
setSearchTerm('')
|
||
}
|
||
}}
|
||
/>
|
||
```
|
||
|
||
## 11. 開發與建置
|
||
|
||
### 11.1 開發環境
|
||
|
||
#### 開發伺服器啟動
|
||
```bash
|
||
cd frontend
|
||
npm run dev
|
||
|
||
# 開發伺服器運行於: http://localhost:3000
|
||
# Hot Reload 啟用
|
||
# Fast Refresh 啟用
|
||
```
|
||
|
||
#### 環境變數配置
|
||
```bash
|
||
# .env.local
|
||
NEXT_PUBLIC_API_URL=http://localhost:5008
|
||
```
|
||
|
||
### 11.2 建置優化
|
||
|
||
#### Next.js 配置 (next.config.js)
|
||
```javascript
|
||
/** @type {import('next').NextConfig} */
|
||
const nextConfig = {
|
||
experimental: {
|
||
appDir: true, // 啟用 App Router
|
||
},
|
||
images: {
|
||
domains: ['localhost'], // 圖片域名白名單
|
||
}
|
||
}
|
||
|
||
module.exports = nextConfig
|
||
```
|
||
|
||
#### TypeScript 配置 (tsconfig.json)
|
||
```json
|
||
{
|
||
"compilerOptions": {
|
||
"target": "es5",
|
||
"lib": ["dom", "dom.iterable", "es6"],
|
||
"allowJs": true,
|
||
"skipLibCheck": true,
|
||
"strict": true,
|
||
"forceConsistentCasingInFileNames": true,
|
||
"noEmit": true,
|
||
"esModuleInterop": true,
|
||
"module": "esnext",
|
||
"moduleResolution": "bundler",
|
||
"resolveJsonModule": true,
|
||
"isolatedModules": true,
|
||
"jsx": "preserve",
|
||
"incremental": true,
|
||
"plugins": [
|
||
{
|
||
"name": "next"
|
||
}
|
||
],
|
||
"baseUrl": ".",
|
||
"paths": {
|
||
"@/*": ["./*"] // 路徑別名
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
## 12. 測試策略
|
||
|
||
### 12.1 組件測試
|
||
```typescript
|
||
// 未來實作:Jest + React Testing Library
|
||
import { render, screen, fireEvent } from '@testing-library/react'
|
||
import { FlashcardForm } from './FlashcardForm'
|
||
|
||
test('should submit form with valid data', async () => {
|
||
const onSuccess = jest.fn()
|
||
render(<FlashcardForm onSuccess={onSuccess} onCancel={() => {}} />)
|
||
|
||
// 填寫表單
|
||
fireEvent.change(screen.getByLabelText('單字'), { target: { value: 'test' } })
|
||
|
||
// 提交表單
|
||
fireEvent.click(screen.getByText('創建詞卡'))
|
||
|
||
// 驗證結果
|
||
expect(onSuccess).toHaveBeenCalled()
|
||
})
|
||
```
|
||
|
||
### 12.2 API 服務測試
|
||
```typescript
|
||
// Mock fetch 進行單元測試
|
||
global.fetch = jest.fn()
|
||
|
||
test('should create flashcard successfully', async () => {
|
||
const mockResponse = { success: true, data: mockFlashcard }
|
||
;(fetch as jest.Mock).mockResolvedValueOnce({
|
||
ok: true,
|
||
json: async () => mockResponse,
|
||
})
|
||
|
||
const result = await flashcardsService.createFlashcard(mockData)
|
||
expect(result.success).toBe(true)
|
||
})
|
||
```
|
||
|
||
---
|
||
|
||
**文檔版本**: v1.0
|
||
**建立日期**: 2025-09-24
|
||
**維護負責**: 前端開發團隊
|
||
**下次審核**: 架構變更時
|
||
|
||
> 📋 相關文檔:
|
||
> - [系統架構總覽](./system-architecture.md)
|
||
> - [後端架構詳細說明](./backend-architecture.md)
|
||
> - [詞卡 API 規格](./flashcard-api-specification.md) |