20 KiB
20 KiB
複習系統前端規格書
版本: 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 # 測試標題組件
🗃️ 數據結構設計
卡片狀態接口
interface CardState extends ApiFlashcard {
// 前端狀態管理欄位
skipCount: number // 跳過次數
wrongCount: number // 答錯次數
successCount: number // 答對次數
isCompleted: boolean // 是否已完成
originalOrder: number // 原始順序索引
// 計算屬性
delayScore: number // 延遲分數 = skipCount + wrongCount
lastAttemptAt: Date // 最後嘗試時間
}
學習會話狀態
interface ReviewSessionState {
// 卡片管理
cards: CardState[]
currentIndex: number
// 進度統計
score: {
correct: number // 答對總數
total: number // 嘗試總數
}
// 會話控制
isComplete: boolean
startTime: Date
// UI狀態
currentMode: 'flip' | 'choice' // 階段2需要
}
⚙️ 核心邏輯函數
延遲計數管理
// 跳過處理
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)
}
}, [])
智能排序系統
// 卡片優先級排序 (您的核心需求)
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)
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)
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
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)
// 完全不呼叫任何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)
// 仍然不呼叫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集成 (遠期)
// 明確的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呼叫判斷邏輯
// 何時使用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)
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 (您調教過的)
/* 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);
}
響應式設計規格
// 智能高度計算 (您的原設計)
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+)
進度持久化
// 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
}
}
🎯 路由和導航設計
頁面路由
// 路由配置
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 生成' }
]
頁面跳轉邏輯
// 會話完成後的跳轉
const handleComplete = () => {
setIsComplete(true)
// 可選: 3秒後自動跳轉
setTimeout(() => {
router.push('/dashboard')
}, 3000)
}
// 中途退出處理
const handleExit = () => {
if (window.confirm('確定要退出複習嗎?進度將不會保存。')) {
router.push('/flashcards')
}
}
📊 性能優化規格
React性能優化
// 使用 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]
)
載入性能目標
// 性能指標
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數據設計
// 測試用的 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 配置
// 嚴格的類型檢查
interface 必須完整定義
Props 必須有明確類型
回調函數必須有返回值類型
狀態更新必須使用正確的類型
// 避免 any 類型
禁止: any, object, Function
建議: 具體的接口定義
ESLint 規則
// 代碼品質規則
'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
📱 使用者體驗規格
載入狀態處理
// 載入狀態
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>
)
錯誤處理
// 錯誤狀態
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>
)
無障礙設計
// 鍵盤操作支援
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)
- 錯誤狀態有適當處理
功能測試標準
- 所有延遲計數測試通過
- 排序邏輯測試通過
- 信心度映射測試通過
- 完整流程集成測試通過
- 邊界條件測試通過
前端規格維護: 開發團隊 更新觸發: 產品需求變更或技術實作調整 目標: 確保前端實作準確性和一致性