779 lines
20 KiB
Markdown
779 lines
20 KiB
Markdown
# 複習系統前端規格書
|
||
|
||
**版本**: 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)
|
||
- [ ] 錯誤狀態有適當處理
|
||
|
||
### **功能測試標準**
|
||
- [ ] 所有延遲計數測試通過
|
||
- [ ] 排序邏輯測試通過
|
||
- [ ] 信心度映射測試通過
|
||
- [ ] 完整流程集成測試通過
|
||
- [ ] 邊界條件測試通過
|
||
|
||
---
|
||
|
||
*前端規格維護: 開發團隊*
|
||
*更新觸發: 產品需求變更或技術實作調整*
|
||
*目標: 確保前端實作準確性和一致性* |