dramaling-vocab-learning/note/複習系統/前端規格.md

779 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 複習系統前端規格書
**版本**: 1.0
**對應**: 技術實作規格.md + 產品需求規格.md
**技術棧**: React 18 + TypeScript + Tailwind CSS
**狀態管理**: React useState (極簡架構)
**最後更新**: 2025-10-03
---
## 📱 **前端架構設計**
### **目錄結構**
```
app/review-simple/
├── page.tsx # 主頁面邏輯
├── data.ts # 數據和類型定義
├── globals.css # 翻卡動畫樣式
└── components/
├── SimpleFlipCard.tsx # 翻卡記憶組件
├── SimpleChoiceTest.tsx # 詞彙選擇組件 (階段2)
├── SimpleProgress.tsx # 進度顯示組件
├── SimpleResults.tsx # 結果統計組件
└── SimpleTestHeader.tsx # 測試標題組件
```
---
## 🗃️ **數據結構設計**
### **卡片狀態接口**
```typescript
interface CardState extends ApiFlashcard {
// 前端狀態管理欄位
skipCount: number // 跳過次數
wrongCount: number // 答錯次數
successCount: number // 答對次數
isCompleted: boolean // 是否已完成
originalOrder: number // 原始順序索引
// 計算屬性
delayScore: number // 延遲分數 = skipCount + wrongCount
lastAttemptAt: Date // 最後嘗試時間
}
```
### **學習會話狀態**
```typescript
interface ReviewSessionState {
// 卡片管理
cards: CardState[]
currentIndex: number
// 進度統計
score: {
correct: number // 答對總數
total: number // 嘗試總數
}
// 會話控制
isComplete: boolean
startTime: Date
// UI狀態
currentMode: 'flip' | 'choice' // 階段2需要
}
```
---
## ⚙️ **核心邏輯函數**
### **延遲計數管理**
```typescript
// 跳過處理
const handleSkip = useCallback((cards: CardState[], currentIndex: number) => {
const updatedCards = cards.map((card, index) =>
index === currentIndex
? {
...card,
skipCount: card.skipCount + 1,
delayScore: card.skipCount + 1 + card.wrongCount,
lastAttemptAt: new Date()
}
: card
)
return {
updatedCards,
nextIndex: getNextCardIndex(updatedCards, currentIndex)
}
}, [])
// 答錯處理
const handleWrongAnswer = useCallback((cards: CardState[], currentIndex: number) => {
const updatedCards = cards.map((card, index) =>
index === currentIndex
? {
...card,
wrongCount: card.wrongCount + 1,
delayScore: card.skipCount + card.wrongCount + 1,
lastAttemptAt: new Date()
}
: card
)
return {
updatedCards,
nextIndex: getNextCardIndex(updatedCards, currentIndex)
}
}, [])
// 答對處理
const handleCorrectAnswer = useCallback((cards: CardState[], currentIndex: number) => {
const updatedCards = cards.map((card, index) =>
index === currentIndex
? {
...card,
isCompleted: true,
successCount: card.successCount + 1,
lastAttemptAt: new Date()
}
: card
)
return {
updatedCards,
nextIndex: getNextCardIndex(updatedCards, currentIndex)
}
}, [])
```
### **智能排序系統**
```typescript
// 卡片優先級排序 (您的核心需求)
const sortCardsByPriority = useCallback((cards: CardState[]): CardState[] => {
return cards.sort((a, b) => {
// 1. 已完成的卡片排到最後
if (a.isCompleted && !b.isCompleted) return 1
if (!a.isCompleted && b.isCompleted) return -1
// 2. 未完成卡片按延遲分數排序 (越少越前面)
const aDelayScore = a.skipCount + a.wrongCount
const bDelayScore = b.skipCount + b.wrongCount
if (aDelayScore !== bDelayScore) {
return aDelayScore - bDelayScore
}
// 3. 延遲分數相同時按原始順序
return a.originalOrder - b.originalOrder
})
}, [])
// 獲取下一張卡片索引
const getNextCardIndex = (cards: CardState[], currentIndex: number): number => {
const sortedCards = sortCardsByPriority(cards)
const incompleteCards = sortedCards.filter(card => !card.isCompleted)
if (incompleteCards.length === 0) return -1 // 全部完成
// 返回排序後第一張未完成卡片的索引
const nextCard = incompleteCards[0]
return cards.findIndex(card => card.id === nextCard.id)
}
```
---
## 🎯 **組件設計規格**
### **SimpleFlipCard.tsx (階段1)**
```typescript
interface SimpleFlipCardProps {
card: CardState
onAnswer: (confidence: 1|2|3) => void // 簡化為3選項
onSkip: () => void
}
// 內部狀態
const [isFlipped, setIsFlipped] = useState(false)
const [selectedConfidence, setSelectedConfidence] = useState<number | null>(null)
const [cardHeight, setCardHeight] = useState<number>(400)
// 信心度選項 (簡化版)
const confidenceOptions = [
{ level: 1, label: '模糊', color: 'bg-orange-100 text-orange-700 border-orange-200' },
{ level: 2, label: '一般', color: 'bg-yellow-100 text-yellow-700 border-yellow-200' },
{ level: 3, label: '熟悉', color: 'bg-green-100 text-green-700 border-green-200' }
]
```
### **SimpleChoiceTest.tsx (階段2)**
```typescript
interface SimpleChoiceTestProps {
card: CardState
options: string[] // 4選1選項
onAnswer: (answer: string) => void
onSkip: () => void
}
// 內部狀態
const [selectedAnswer, setSelectedAnswer] = useState<string | null>(null)
const [showResult, setShowResult] = useState(false)
// 答案驗證
const isCorrect = useMemo(() => selectedAnswer === card.word, [selectedAnswer, card.word])
```
### **SimpleProgress.tsx**
```typescript
interface SimpleProgressProps {
cards: CardState[]
currentIndex: number
score: { correct: number; total: number }
}
// 進度計算
const completedCount = cards.filter(card => card.isCompleted).length
const totalCount = cards.length
const progressPercentage = (completedCount / totalCount) * 100
// 延遲統計 (顯示跳過次數)
const delayedCards = cards.filter(card => card.skipCount + card.wrongCount > 0)
const totalSkips = cards.reduce((sum, card) => sum + card.skipCount, 0)
const totalWrongs = cards.reduce((sum, card) => sum + card.wrongCount, 0)
```
---
## 🌐 **API呼叫策略** (各階段明確區分)
### **階段1: 純靜態數據 (當前MVP)**
```typescript
// 完全不呼叫任何API
export default function SimpleReviewPage() {
useEffect(() => {
// 直接使用靜態數據,無網路依賴
const staticCards = SIMPLE_CARDS.map((card, index) => ({
...card,
skipCount: 0,
wrongCount: 0,
// ... 其他前端狀態
}))
setCards(staticCards)
}, [])
// 答題完成時只更新前端狀態不呼叫API
const handleAnswer = (confidence: number) => {
// 純前端邏輯無API調用
updateLocalState(confidence)
}
}
```
### **階段2: 本地持久化 (localStorage)**
```typescript
// 仍然不呼叫API只添加本地存儲
export default function SimpleReviewPage() {
useEffect(() => {
// 1. 嘗試從localStorage載入
const savedProgress = loadFromLocalStorage()
if (savedProgress && isSameDay(savedProgress.timestamp)) {
setCards(savedProgress.cards)
setCurrentIndex(savedProgress.currentIndex)
} else {
// 2. 無有效存檔則使用靜態數據
setCards(SIMPLE_CARDS.map(addStateFields))
}
}, [])
// 答題時:更新狀態 + 保存到localStorage
const handleAnswer = (confidence: number) => {
const newState = updateLocalState(confidence)
saveToLocalStorage(newState) // 本地持久化
// 仍然不呼叫API
}
}
```
### **階段3: API集成 (遠期)**
```typescript
// 明確的API呼叫時機和策略
export default function SimpleReviewPage() {
const [dataSource, setDataSource] = useState<'static' | 'api'>('static')
useEffect(() => {
const loadCards = async () => {
setLoading(true)
try {
// 嘗試API呼叫
const response = await fetch('/api/flashcards/due?limit=10', {
headers: {
'Authorization': `Bearer ${getAuthToken()}`
}
})
if (response.ok) {
const apiData = await response.json()
setCards(apiData.data.flashcards.map(addStateFields))
setDataSource('api')
} else {
throw new Error('API failed')
}
} catch (error) {
console.warn('API unavailable, using static data:', error)
// 降級到靜態數據
setCards(SIMPLE_CARDS.map(addStateFields))
setDataSource('static')
} finally {
setLoading(false)
}
}
loadCards()
}, [])
// 答題時的API呼叫邏輯
const handleAnswer = async (confidence: number) => {
// 1. 立即更新前端狀態 (即時響應)
const newState = updateLocalState(confidence)
// 2. 如果使用API同步到後端
if (dataSource === 'api') {
try {
await fetch(`/api/flashcards/${currentCard.id}/review`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${getAuthToken()}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
confidence: confidence,
isCorrect: confidence >= 2
})
})
} catch (error) {
console.warn('Failed to sync to backend:', error)
// 前端狀態已更新API失敗不影響用戶體驗
}
}
}
}
```
### **API呼叫判斷邏輯**
```typescript
// 何時使用API vs 靜態數據
const determineDataSource = () => {
// 檢查是否有認證Token
const hasAuth = getAuthToken() !== null
// 檢查是否在生產環境
const isProduction = process.env.NODE_ENV === 'production'
// 檢查是否明確要求使用API
const forceApi = window.location.search.includes('api=true')
return (hasAuth && isProduction) || forceApi ? 'api' : 'static'
}
```
---
## 🔄 **狀態管理設計**
### **主頁面狀態 (page.tsx)**
```typescript
export default function SimpleReviewPage() {
// 核心狀態
const [cards, setCards] = useState<CardState[]>([])
const [currentIndex, setCurrentIndex] = useState(0)
const [score, setScore] = useState({ correct: 0, total: 0 })
const [isComplete, setIsComplete] = useState(false)
const [mode, setMode] = useState<'flip' | 'choice'>('flip') // 階段2需要
// 初始化卡片狀態
useEffect(() => {
const initialCards: CardState[] = SIMPLE_CARDS.map((card, index) => ({
...card,
skipCount: 0,
wrongCount: 0,
successCount: 0,
isCompleted: false,
originalOrder: index,
delayScore: 0,
lastAttemptAt: new Date()
}))
setCards(initialCards)
}, [])
// 答題處理
const handleAnswer = useCallback((confidence: number) => {
const isCorrect = confidence >= 2 // 一般以上算答對
if (isCorrect) {
const result = handleCorrectAnswer(cards, currentIndex)
setCards(result.updatedCards)
setCurrentIndex(result.nextIndex)
} else {
const result = handleWrongAnswer(cards, currentIndex)
setCards(result.updatedCards)
setCurrentIndex(result.nextIndex)
}
// 更新分數統計
setScore(prev => ({
correct: prev.correct + (isCorrect ? 1 : 0),
total: prev.total + 1
}))
// 檢查是否完成
checkIfComplete(result.updatedCards)
}, [cards, currentIndex])
// 跳過處理
const handleSkipCard = useCallback(() => {
const result = handleSkip(cards, currentIndex)
setCards(result.updatedCards)
setCurrentIndex(result.nextIndex)
checkIfComplete(result.updatedCards)
}, [cards, currentIndex])
}
```
---
## 🎨 **UI/UX規格**
### **翻卡動畫CSS** (您調教過的)
```css
/* 3D翻卡動畫 */
.flip-card-container {
perspective: 1000px;
}
.flip-card {
transform-style: preserve-3d;
transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
.flip-card.flipped {
transform: rotateY(180deg);
}
.flip-card-front,
.flip-card-back {
backface-visibility: hidden;
position: absolute;
width: 100%;
height: 100%;
}
.flip-card-back {
transform: rotateY(180deg);
}
```
### **響應式設計規格**
```typescript
// 智能高度計算 (您的原設計)
const calculateCardHeight = useCallback(() => {
if (backRef.current) {
const backHeight = backRef.current.scrollHeight
const minHeight = window.innerWidth <= 480 ? 300 :
window.innerWidth <= 768 ? 350 : 400
return Math.max(minHeight, backHeight)
}
return 400
}, [])
// 信心度按鈕響應式
const buttonLayout = window.innerWidth <= 640
? 'grid-cols-1 gap-2' // 手機版: 垂直排列
: 'grid-cols-3 gap-3' // 桌面版: 水平排列
```
---
## 🔄 **本地存儲設計** (階段2+)
### **進度持久化**
```typescript
// localStorage 結構
interface StoredProgress {
sessionId: string
cards: CardState[]
currentIndex: number
score: { correct: number; total: number }
lastSaveTime: string
}
// 儲存進度
const saveProgress = (cards: CardState[], currentIndex: number, score: any) => {
const progress: StoredProgress = {
sessionId: `review_${Date.now()}`,
cards,
currentIndex,
score,
lastSaveTime: new Date().toISOString()
}
localStorage.setItem('review-progress', JSON.stringify(progress))
}
// 載入進度
const loadProgress = (): StoredProgress | null => {
const saved = localStorage.getItem('review-progress')
if (!saved) return null
try {
const progress = JSON.parse(saved)
// 檢查是否是當日進度 (避免過期數據)
const saveTime = new Date(progress.lastSaveTime)
const now = new Date()
const isToday = saveTime.toDateString() === now.toDateString()
return isToday ? progress : null
} catch {
return null
}
}
```
---
## 🎯 **路由和導航設計**
### **頁面路由**
```typescript
// 路由配置
const reviewRoutes = {
main: '/review-simple', // 主複習頁面
maintenance: '/review', // 維護頁面 (舊版本隔離)
backup: '/review-old' // 備份頁面 (複雜版本)
}
// 導航更新
const navigationItems = [
{ href: '/dashboard', label: '儀表板' },
{ href: '/flashcards', label: '詞卡' },
{ href: '/review-simple', label: '複習' }, // 指向可用版本
{ href: '/generate', label: 'AI 生成' }
]
```
### **頁面跳轉邏輯**
```typescript
// 會話完成後的跳轉
const handleComplete = () => {
setIsComplete(true)
// 可選: 3秒後自動跳轉
setTimeout(() => {
router.push('/dashboard')
}, 3000)
}
// 中途退出處理
const handleExit = () => {
if (window.confirm('確定要退出複習嗎?進度將不會保存。')) {
router.push('/flashcards')
}
}
```
---
## 📊 **性能優化規格**
### **React性能優化**
```typescript
// 使用 memo 避免不必要重渲染
export const SimpleFlipCard = memo(SimpleFlipCardComponent)
export const SimpleProgress = memo(SimpleProgressComponent)
// useCallback 穩定化函數引用
const handleAnswer = useCallback((confidence: number) => {
// ... 邏輯
}, [cards, currentIndex])
// useMemo 緩存計算結果
const sortedCards = useMemo(() =>
sortCardsByPriority(cards),
[cards]
)
const currentCard = useMemo(() =>
cards[currentIndex],
[cards, currentIndex]
)
```
### **載入性能目標**
```typescript
// 性能指標
const PERFORMANCE_TARGETS = {
INITIAL_LOAD: 1500, // 初始載入 < 1.5秒
CARD_FLIP: 300, // 翻卡動畫 < 300ms
SORT_OPERATION: 100, // 排序計算 < 100ms
STATE_UPDATE: 50, // 狀態更新 < 50ms
NAVIGATION: 200 // 頁面跳轉 < 200ms
}
// 性能監控
const measurePerformance = (operation: string, fn: Function) => {
const start = performance.now()
const result = fn()
const end = performance.now()
console.log(`${operation}: ${end - start}ms`)
return result
}
```
---
## 🧪 **測試架構**
### **測試文件結構**
```
__tests__/
├── delay-counting-system.test.ts # 延遲計數邏輯測試
├── card-sorting.test.ts # 排序算法測試
├── confidence-mapping.test.ts # 信心度映射測試
└── components/
├── SimpleFlipCard.test.tsx # 翻卡組件測試
├── SimpleProgress.test.tsx # 進度組件測試
└── integration.test.tsx # 完整流程集成測試
```
### **Mock數據設計**
```typescript
// 測試用的 Mock 數據
export const MOCK_CARDS: CardState[] = [
{
id: 'test-1',
word: 'evidence',
definition: 'facts or information indicating truth',
skipCount: 0,
wrongCount: 0,
successCount: 0,
isCompleted: false,
originalOrder: 0,
delayScore: 0,
// ... 其他 API 欄位
},
{
id: 'test-2',
word: 'priority',
definition: 'the fact of being more important',
skipCount: 2,
wrongCount: 1,
successCount: 0,
isCompleted: false,
originalOrder: 1,
delayScore: 3,
// ... 其他 API 欄位
}
]
```
---
## 🔧 **開發工具配置**
### **TypeScript 配置**
```typescript
// 嚴格的類型檢查
interface 必須完整定義
Props 必須有明確類型
回調函數必須有返回值類型
狀態更新必須使用正確的類型
// 避免 any 類型
禁止: any, object, Function
建議: 具體的接口定義
```
### **ESLint 規則**
```typescript
// 代碼品質規則
'react-hooks/exhaustive-deps': 'error' // 確保 useEffect 依賴正確
'react/no-array-index-key': 'warn' // 避免使用 index 作為 key
'@typescript-eslint/no-unused-vars': 'error' // 禁止未使用變數
// 複雜度控制
'max-lines': ['error', 200] // 組件最多200行
'max-params': ['error', 5] // 函數最多5個參數
'complexity': ['error', 10] // 圈複雜度最多10
```
---
## 📱 **使用者體驗規格**
### **載入狀態處理**
```typescript
// 載入狀態
const [isLoading, setIsLoading] = useState(true)
// 載入動畫
const LoadingSpinner = () => (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
<span className="ml-3 text-gray-600">準備詞卡中...</span>
</div>
)
```
### **錯誤處理**
```typescript
// 錯誤狀態
const [error, setError] = useState<string | null>(null)
// 錯誤邊界
const ErrorBoundary = ({ error, onRetry }) => (
<div className="bg-red-50 border border-red-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-red-700 mb-2">發生錯誤</h3>
<p className="text-red-600 mb-4">{error}</p>
<button
onClick={onRetry}
className="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700"
>
重新嘗試
</button>
</div>
)
```
### **無障礙設計**
```typescript
// 鍵盤操作支援
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowLeft': handleSkip(); break
case 'ArrowRight': handleAnswer(2); break // 一般
case 'ArrowUp': handleAnswer(3); break // 熟悉
case 'ArrowDown': handleAnswer(1); break // 模糊
case ' ': handleFlip(); break // 空格翻卡
}
}
// ARIA 標籤
<button
aria-label={`信心度選擇: ${label}`}
role="button"
tabIndex={0}
>
{label}
</button>
```
---
## 📋 **開發檢查清單**
### **組件開發完成標準**
- [ ] TypeScript 無錯誤和警告
- [ ] 所有 props 都有預設值或必填檢查
- [ ] 使用 memo/useCallback 優化性能
- [ ] 響應式設計在手機和桌面都正常
- [ ] 無障礙功能完整 (鍵盤、ARIA)
- [ ] 錯誤狀態有適當處理
### **功能測試標準**
- [ ] 所有延遲計數測試通過
- [ ] 排序邏輯測試通過
- [ ] 信心度映射測試通過
- [ ] 完整流程集成測試通過
- [ ] 邊界條件測試通過
---
*前端規格維護: 開發團隊*
*更新觸發: 產品需求變更或技術實作調整*
*目標: 確保前端實作準確性和一致性*