feat: 重構並整合 review-simple 組件系統

## 主要變更
- 重新組織檔案結構到標準 Next.js 目錄
- 簡化並整合 VocabChoiceTest 組件
- 優化狀態管理架構
- 統一兩種測驗類型的設計風格

## 檔案重組
- components/review/simple/ - 統一測驗組件
- hooks/review/ - 複習相關 Hook
- lib/data/ - 數據管理
- note/archive/ - 舊複雜系統備份

## 新功能
- SimpleFlipCard: 翻卡記憶測驗 (信心度 0-2)
- VocabChoiceTest: 詞彙選擇測驗 (正確2分/錯誤0分)
- 統一接口設計和用戶體驗流程

## 技術改進
- 移除過度複雜的依賴系統
- 使用 useReducer 優化狀態管理
- useMemo 提升性能
- 統一設計語言和組件風格

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
鄭沛軒 2025-10-04 23:44:31 +08:00
parent 914c981c4b
commit 04def4bb85
62 changed files with 591 additions and 407 deletions

View File

@ -1,210 +0,0 @@
import { useReducer, useEffect, useMemo } from 'react'
import { SIMPLE_CARDS, CardState, sortCardsByPriority } from '../data'
interface ReviewState {
cards: CardState[]
score: { correct: number; total: number }
isComplete: boolean
}
type ReviewAction =
| { type: 'LOAD_PROGRESS'; payload: ReviewState }
| { type: 'ANSWER_CARD'; payload: { cardId: string; confidence: number } }
| { type: 'SKIP_CARD'; payload: { cardId: string } }
| { type: 'RESTART' }
// 內部狀態更新函數
const updateCardState = (
cards: CardState[],
cardIndex: number,
updates: Partial<CardState>
): CardState[] => {
return cards.map((card, index) =>
index === cardIndex
? { ...card, ...updates }
: card
)
}
const reviewReducer = (state: ReviewState, action: ReviewAction): ReviewState => {
switch (action.type) {
case 'LOAD_PROGRESS':
return action.payload
case 'ANSWER_CARD': {
const { cardId, confidence } = action.payload
const isCorrect = confidence >= 2
// 使用 Map 優化查找性能
const cardMap = new Map(state.cards.map((card, index) => [card.id, { card, index }]))
const cardData = cardMap.get(cardId)
if (!cardData) return state
const { index: cardIndex } = cardData
const currentCard = state.cards[cardIndex]
const updatedCards = updateCardState(state.cards, cardIndex, {
isCompleted: isCorrect,
wrongCount: isCorrect ? currentCard.wrongCount : currentCard.wrongCount + 1
})
const newScore = {
correct: state.score.correct + (isCorrect ? 1 : 0),
total: state.score.total + 1
}
const remainingCards = updatedCards.filter(card => !card.isCompleted)
const isComplete = remainingCards.length === 0
return {
cards: updatedCards,
score: newScore,
isComplete
}
}
case 'SKIP_CARD': {
const { cardId } = action.payload
// 使用 Map 優化查找性能
const cardMap = new Map(state.cards.map((card, index) => [card.id, { card, index }]))
const cardData = cardMap.get(cardId)
if (!cardData) return state
const { index: cardIndex } = cardData
const currentCard = state.cards[cardIndex]
const updatedCards = updateCardState(state.cards, cardIndex, {
skipCount: currentCard.skipCount + 1
})
const remainingCards = updatedCards.filter(card => !card.isCompleted)
const isComplete = remainingCards.length === 0
return {
cards: updatedCards,
score: state.score,
isComplete
}
}
case 'RESTART':
return {
cards: SIMPLE_CARDS,
score: { correct: 0, total: 0 },
isComplete: false
}
default:
return state
}
}
export function useReviewSession() {
// 使用 useReducer 統一狀態管理
const [state, dispatch] = useReducer(reviewReducer, {
cards: SIMPLE_CARDS,
score: { correct: 0, total: 0 },
isComplete: false
})
const { cards, score, isComplete } = state
// 智能排序獲取當前卡片 - 使用 useMemo 優化性能
const sortedCards = useMemo(() => sortCardsByPriority(cards), [cards])
const incompleteCards = useMemo(() =>
sortedCards.filter((card: CardState) => !card.isCompleted),
[sortedCards]
)
const currentCard = incompleteCards[0] // 總是選擇優先級最高的未完成卡片
// localStorage進度保存和載入
useEffect(() => {
// 載入保存的進度
const savedProgress = localStorage.getItem('review-progress')
if (savedProgress) {
try {
const parsed = JSON.parse(savedProgress)
const saveTime = new Date(parsed.timestamp)
const now = new Date()
const isToday = saveTime.toDateString() === now.toDateString()
if (isToday && parsed.cards) {
dispatch({
type: 'LOAD_PROGRESS',
payload: {
cards: parsed.cards,
score: parsed.score || { correct: 0, total: 0 },
isComplete: parsed.isComplete || false
}
})
console.log('📖 載入保存的複習進度')
}
} catch (error) {
console.warn('進度載入失敗:', error)
localStorage.removeItem('review-progress')
}
}
}, [])
// 保存進度到localStorage
const saveProgress = () => {
const progress = {
cards,
score,
isComplete,
timestamp: new Date().toISOString()
}
localStorage.setItem('review-progress', JSON.stringify(progress))
console.log('💾 進度已保存')
}
// 處理答題 - 使用 dispatch 統一管理
const handleAnswer = (confidence: number) => {
if (!currentCard) return
dispatch({
type: 'ANSWER_CARD',
payload: { cardId: currentCard.id, confidence }
})
// 保存進度
setTimeout(() => saveProgress(), 100) // 延遲一點確保狀態更新
}
// 處理跳過 - 使用 dispatch 統一管理
const handleSkip = () => {
if (!currentCard) return
dispatch({
type: 'SKIP_CARD',
payload: { cardId: currentCard.id }
})
// 保存進度
setTimeout(() => saveProgress(), 100) // 延遲一點確保狀態更新
}
// 重新開始 - 重置所有狀態
const handleRestart = () => {
dispatch({ type: 'RESTART' })
localStorage.removeItem('review-progress') // 清除保存的進度
console.log('🔄 複習進度已重置')
}
return {
// 狀態
cards,
score,
isComplete,
currentCard,
sortedCards,
// 動作
handleAnswer,
handleSkip,
handleRestart
}
}

View File

@ -2,11 +2,11 @@
import { Navigation } from '@/components/shared/Navigation' import { Navigation } from '@/components/shared/Navigation'
import './globals.css' import './globals.css'
import { SimpleFlipCard } from './components/SimpleFlipCard' import { SimpleFlipCard } from '@/components/review/simple/SimpleFlipCard'
import { SimpleProgress } from './components/SimpleProgress' import { SimpleProgress } from '@/components/review/simple/SimpleProgress'
import { SimpleResults } from './components/SimpleResults' import { SimpleResults } from '@/components/review/simple/SimpleResults'
import { SIMPLE_CARDS, CardState } from './data' import { SIMPLE_CARDS, CardState } from '@/lib/data/reviewSimpleData'
import { useReviewSession } from './hooks/useReviewSession' import { useReviewSession } from '@/hooks/review/useReviewSession'
export default function SimpleReviewPage() { export default function SimpleReviewPage() {
// 使用自定義 Hook 管理複習狀態 // 使用自定義 Hook 管理複習狀態

View File

@ -0,0 +1,124 @@
'use client'
import { useState } from 'react'
import { Navigation } from '@/components/shared/Navigation'
import { VocabChoiceTest } from '@/components/review/simple/VocabChoiceTest'
import { SIMPLE_CARDS, CardState } from '@/lib/data/reviewSimpleData'
export default function TestVocabChoicePage() {
const [currentCardIndex, setCurrentCardIndex] = useState(0)
const [score, setScore] = useState({ correct: 0, total: 0 })
const currentCard = SIMPLE_CARDS[currentCardIndex]
// 生成測驗選項 (包含正確答案和3個干擾項)
const generateOptions = (correctWord: string) => {
const allWords = SIMPLE_CARDS.map(card => card.word).filter(word => word !== correctWord)
const shuffledWords = allWords.sort(() => Math.random() - 0.5)
const distractors = shuffledWords.slice(0, 3)
const options = [correctWord, ...distractors]
return options.sort(() => Math.random() - 0.5) // 隨機排列選項
}
const handleAnswer = (confidence: number) => {
const isCorrect = confidence >= 2
setScore(prev => ({
correct: prev.correct + (isCorrect ? 1 : 0),
total: prev.total + 1
}))
// 移動到下一張卡片
setTimeout(() => {
if (currentCardIndex < SIMPLE_CARDS.length - 1) {
setCurrentCardIndex(prev => prev + 1)
} else {
alert(`測試完成!正確率: ${score.correct + (isCorrect ? 1 : 0)}/${score.total + 1}`)
}
}, 100)
}
const handleSkip = () => {
setScore(prev => ({ ...prev, total: prev.total + 1 }))
if (currentCardIndex < SIMPLE_CARDS.length - 1) {
setCurrentCardIndex(prev => prev + 1)
} else {
alert(`測試完成!正確率: ${score.correct}/${score.total + 1}`)
}
}
const handleRestart = () => {
setCurrentCardIndex(0)
setScore({ correct: 0, total: 0 })
}
if (!currentCard) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
<Navigation />
<div className="py-8">
<div className="max-w-4xl mx-auto px-4 text-center">
<h1 className="text-2xl font-bold mb-4"></h1>
<button
onClick={handleRestart}
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
</button>
</div>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
<Navigation />
<div className="py-8">
<div className="max-w-4xl mx-auto px-4">
{/* 進度顯示 */}
<div className="mb-6 bg-white rounded-lg shadow p-4">
<div className="flex justify-between items-center">
<h1 className="text-xl font-bold"></h1>
<div className="text-sm text-gray-600">
{currentCardIndex + 1} / {SIMPLE_CARDS.length}
</div>
</div>
<div className="mt-2">
<div className="text-sm text-gray-600 mb-1">
: {score.correct} / {score.total}
{score.total > 0 && ` (${Math.round(score.correct / score.total * 100)}%)`}
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${((currentCardIndex) / SIMPLE_CARDS.length) * 100}%` }}
/>
</div>
</div>
</div>
{/* 測驗組件 */}
<VocabChoiceTest
card={currentCard}
options={generateOptions(currentCard.word)}
onAnswer={handleAnswer}
onSkip={handleSkip}
/>
{/* 控制按鈕 */}
<div className="mt-6 text-center">
<button
onClick={handleRestart}
className="px-4 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 mr-4"
>
</button>
</div>
</div>
</div>
</div>
)
}

View File

@ -1,87 +0,0 @@
import React, { useState, useCallback, useMemo, memo } from 'react'
import { ChoiceTestProps } from '@/types/review'
import {
TestResultDisplay,
ChoiceTestContainer,
ChoiceGrid
} from '@/components/review/shared'
interface VocabChoiceTestProps extends ChoiceTestProps {}
const VocabChoiceTestComponent: React.FC<VocabChoiceTestProps> = ({
cardData,
options,
onAnswer,
onReportError,
disabled = false
}) => {
const [selectedAnswer, setSelectedAnswer] = useState<string | null>(null)
const [showResult, setShowResult] = useState(false)
const handleAnswerSelect = useCallback((answer: string) => {
if (disabled || showResult) return
setSelectedAnswer(answer)
setShowResult(true)
onAnswer(answer)
}, [disabled, showResult, onAnswer])
const isCorrect = useMemo(() =>
selectedAnswer === cardData.word
, [selectedAnswer, cardData.word])
// 問題顯示區域
const questionArea = (
<div className="text-center">
<div className="bg-gray-50 rounded-lg p-6">
<h3 className="font-semibold text-gray-900 mb-3 text-left"></h3>
<p className="text-gray-700 text-left text-lg leading-relaxed">{cardData.definition}</p>
</div>
<p className="text-lg text-gray-700 mt-4 text-left">
</p>
</div>
)
// 選項區域
const optionsArea = (
<ChoiceGrid
options={options}
selectedOption={selectedAnswer}
correctAnswer={cardData.word}
showResult={showResult}
onSelect={handleAnswerSelect}
disabled={disabled}
/>
)
// 結果顯示區域
const resultArea = showResult ? (
<TestResultDisplay
isCorrect={isCorrect}
correctAnswer={cardData.word}
userAnswer={selectedAnswer || ''}
word={cardData.word}
pronunciation={cardData.pronunciation}
example={cardData.example}
exampleTranslation={cardData.exampleTranslation}
showResult={showResult}
/>
) : null
return (
<ChoiceTestContainer
cardData={cardData}
testTitle="詞彙選擇"
questionArea={questionArea}
optionsArea={optionsArea}
resultArea={resultArea}
onAnswer={onAnswer}
onReportError={onReportError}
disabled={disabled}
/>
)
}
export const VocabChoiceTest = memo(VocabChoiceTestComponent)
VocabChoiceTest.displayName = 'VocabChoiceTest'

View File

@ -1,7 +1,7 @@
'use client' 'use client'
import { useState, useRef, useEffect, useCallback } from 'react' import { useState, useRef, useEffect, useCallback } from 'react'
import { CardState } from '../data' import { CardState } from '@/lib/data/reviewSimpleData'
import { SimpleTestHeader } from './SimpleTestHeader' import { SimpleTestHeader } from './SimpleTestHeader'
interface SimpleFlipCardProps { interface SimpleFlipCardProps {
@ -188,9 +188,9 @@ export function SimpleFlipCard({ card, onAnswer, onSkip }: SimpleFlipCardProps)
</h3> </h3>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
{[ {[
{ level: 1, label: '模糊', color: 'bg-orange-100 text-orange-700 border-orange-200 hover:bg-orange-200' }, { level: 0, label: '模糊', color: 'bg-orange-100 text-orange-700 border-orange-200 hover:bg-orange-200' },
{ level: 2, label: '一般', color: 'bg-yellow-100 text-yellow-700 border-yellow-200 hover:bg-yellow-200' }, { level: 1, label: '一般', color: 'bg-yellow-100 text-yellow-700 border-yellow-200 hover:bg-yellow-200' },
{ level: 3, label: '熟悉', color: 'bg-green-100 text-green-700 border-green-200 hover:bg-green-200' } { level: 2, label: '熟悉', color: 'bg-green-100 text-green-700 border-green-200 hover:bg-green-200' }
].map(({ level, label, color }) => { ].map(({ level, label, color }) => {
const isSelected = selectedConfidence === level const isSelected = selectedConfidence === level
return ( return (

View File

@ -1,4 +1,4 @@
import { CardState } from '../data' import { CardState } from '@/lib/data/reviewSimpleData'
interface SimpleProgressProps { interface SimpleProgressProps {
current: number current: number

View File

@ -0,0 +1,147 @@
'use client'
import { useState, useCallback } from 'react'
import { CardState } from '@/lib/data/reviewSimpleData'
import { SimpleTestHeader } from './SimpleTestHeader'
interface VocabChoiceTestProps {
card: CardState
options: string[]
onAnswer: (confidence: number) => void
onSkip: () => void
}
export function VocabChoiceTest({ card, options, onAnswer, onSkip }: VocabChoiceTestProps) {
const [selectedAnswer, setSelectedAnswer] = useState<string | null>(null)
const [showResult, setShowResult] = useState(false)
const handleAnswerSelect = useCallback((answer: string) => {
if (showResult) return
setSelectedAnswer(answer)
setShowResult(true)
// 判斷答案是否正確正確給3分錯誤給1分
const isCorrect = answer === card.word
const confidence = isCorrect ? 2 : 0
// 延遲一點再調用回調,讓用戶看到選擇結果
setTimeout(() => {
onAnswer(confidence)
// 重置狀態為下一題準備
setSelectedAnswer(null)
setShowResult(false)
}, 1500)
}, [showResult, card.word, onAnswer])
const handleSkipClick = useCallback(() => {
onSkip()
}, [onSkip])
const isCorrect = selectedAnswer === card.word
return (
<div className="bg-white rounded-xl shadow-lg p-8">
<SimpleTestHeader
title="詞彙選擇"
cefr={card.cefr}
/>
{/* 問題區域 */}
<div className="mb-8">
<div className="bg-gray-50 rounded-lg p-6 mb-4">
<h3 className="font-semibold text-gray-900 mb-3 text-left"></h3>
<p className="text-gray-700 text-left text-lg leading-relaxed">{card.definition}</p>
</div>
<p className="text-lg text-gray-700 text-left">
</p>
</div>
{/* 選項區域 */}
<div className="mb-6">
<div className="grid grid-cols-2 gap-3">
{options.map((option, index) => {
const isSelected = selectedAnswer === option
const isCorrectOption = option === card.word
let buttonClass = 'p-4 rounded-lg border-2 text-center font-medium transition-all duration-200 cursor-pointer active:scale-95'
if (showResult) {
if (isSelected && isCorrectOption) {
// 選中且正確
buttonClass += ' bg-green-100 text-green-700 border-green-200 ring-2 ring-green-400'
} else if (isSelected && !isCorrectOption) {
// 選中但錯誤
buttonClass += ' bg-red-100 text-red-700 border-red-200 ring-2 ring-red-400'
} else if (!isSelected && isCorrectOption) {
// 未選中但是正確答案
buttonClass += ' bg-green-50 text-green-600 border-green-200'
} else {
// 未選中且非正確答案
buttonClass += ' bg-gray-50 text-gray-500 border-gray-200'
}
} else {
// 未答題狀態
buttonClass += ' bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100'
}
return (
<button
key={index}
onClick={() => handleAnswerSelect(option)}
disabled={showResult}
className={buttonClass}
>
{option}
</button>
)
})}
</div>
</div>
{/* 結果顯示區域 */}
{showResult && (
<div className="mb-6">
<div className={`p-4 rounded-lg ${isCorrect ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}`}>
<div className="flex items-center mb-3">
<span className={`text-2xl mr-3 ${isCorrect ? 'text-green-600' : 'text-red-600'}`}>
{isCorrect ? '✅' : '❌'}
</span>
<h3 className={`text-lg font-semibold ${isCorrect ? 'text-green-800' : 'text-red-800'}`}>
{isCorrect ? '答對了!' : '答錯了'}
</h3>
</div>
<div className="space-y-2 text-left">
<p className="text-gray-700">
<strong></strong> {card.word}
</p>
<p className="text-gray-700">
<strong></strong> {card.pronunciation}
</p>
<p className="text-gray-700">
<strong></strong> "{card.example}"
</p>
<p className="text-gray-600 text-sm">
{card.exampleTranslation}
</p>
</div>
</div>
</div>
)}
{/* 跳過按鈕 */}
{!showResult && (
<div className="mt-6">
<button
onClick={handleSkipClick}
className="w-full border border-gray-300 text-gray-700 py-3 px-4 rounded-lg font-medium hover:bg-gray-50 transition-colors"
>
</button>
</div>
)}
</div>
)
}

View File

@ -1,84 +1,210 @@
/** import { useReducer, useEffect, useMemo } from 'react'
* Hook - Store import { SIMPLE_CARDS, CardState, sortCardsByPriority } from '@/lib/data/reviewSimpleData'
* 便 Store Store
*/
import { useReviewSessionStore } from '@/store/review/useReviewSessionStore' interface ReviewState {
import { flashcardsService } from '@/lib/services/flashcards' cards: CardState[]
import type { ExtendedFlashcard, ReviewMode } from '@/lib/types/review' score: { correct: number; total: number }
isComplete: boolean
interface UseReviewSessionReturn {
// 狀態 (從 Store 直接取得)
currentCard: ExtendedFlashcard | null
dueCards: ExtendedFlashcard[]
currentCardIndex: number
isLoadingCard: boolean
mode: ReviewMode
isAutoSelecting: boolean
showNoDueCards: boolean
showComplete: boolean
// 操作 (Store 的包裝方法)
loadNextCard: () => Promise<void>
setCurrentCard: (card: ExtendedFlashcard | null) => void
setCurrentCardIndex: (index: number) => void
setMode: (mode: ReviewMode) => void
setAutoSelecting: (auto: boolean) => void
setShowNoDueCards: (show: boolean) => void
setShowComplete: (show: boolean) => void
resetSession: () => void
} }
export function useReviewSession(): UseReviewSessionReturn { type ReviewAction =
// 從 Store 取得狀態和操作 | { type: 'LOAD_PROGRESS'; payload: ReviewState }
const store = useReviewSessionStore() | { type: 'ANSWER_CARD'; payload: { cardId: string; confidence: number } }
| { type: 'SKIP_CARD'; payload: { cardId: string } }
| { type: 'RESTART' }
// 載入卡片的業務邏輯 (唯一的 Hook 專有邏輯) // 內部狀態更新函數
const loadNextCard = async () => { const updateCardState = (
try { cards: CardState[],
store.setLoading(true) cardIndex: number,
store.setError(null) updates: Partial<CardState>
): CardState[] => {
return cards.map((card, index) =>
index === cardIndex
? { ...card, ...updates }
: card
)
}
const result = await flashcardsService.getDueFlashcards(50) const reviewReducer = (state: ReviewState, action: ReviewAction): ReviewState => {
switch (action.type) {
case 'LOAD_PROGRESS':
return action.payload
if (result.success && result.data && result.data.length > 0) { case 'ANSWER_CARD': {
store.setDueCards(result.data) const { cardId, confidence } = action.payload
store.setCurrentCard(result.data[0]) const isCorrect = confidence >= 2
store.setCurrentCardIndex(0)
store.setShowNoDueCards(false) // 使用 Map 優化查找性能
} else { const cardMap = new Map(state.cards.map((card, index) => [card.id, { card, index }]))
store.setShowNoDueCards(true) const cardData = cardMap.get(cardId)
store.setCurrentCard(null)
store.setDueCards([]) if (!cardData) return state
const { index: cardIndex } = cardData
const currentCard = state.cards[cardIndex]
const updatedCards = updateCardState(state.cards, cardIndex, {
isCompleted: isCorrect,
wrongCount: isCorrect ? currentCard.wrongCount : currentCard.wrongCount + 1
})
const newScore = {
correct: state.score.correct + (isCorrect ? 1 : 0),
total: state.score.total + 1
}
const remainingCards = updatedCards.filter(card => !card.isCompleted)
const isComplete = remainingCards.length === 0
return {
cards: updatedCards,
score: newScore,
isComplete
} }
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '載入卡片失敗'
store.setError(errorMessage)
store.setShowNoDueCards(true)
} finally {
store.setLoading(false)
} }
case 'SKIP_CARD': {
const { cardId } = action.payload
// 使用 Map 優化查找性能
const cardMap = new Map(state.cards.map((card, index) => [card.id, { card, index }]))
const cardData = cardMap.get(cardId)
if (!cardData) return state
const { index: cardIndex } = cardData
const currentCard = state.cards[cardIndex]
const updatedCards = updateCardState(state.cards, cardIndex, {
skipCount: currentCard.skipCount + 1
})
const remainingCards = updatedCards.filter(card => !card.isCompleted)
const isComplete = remainingCards.length === 0
return {
cards: updatedCards,
score: state.score,
isComplete
}
}
case 'RESTART':
return {
cards: SIMPLE_CARDS,
score: { correct: 0, total: 0 },
isComplete: false
}
default:
return state
}
}
export function useReviewSession() {
// 使用 useReducer 統一狀態管理
const [state, dispatch] = useReducer(reviewReducer, {
cards: SIMPLE_CARDS,
score: { correct: 0, total: 0 },
isComplete: false
})
const { cards, score, isComplete } = state
// 智能排序獲取當前卡片 - 使用 useMemo 優化性能
const sortedCards = useMemo(() => sortCardsByPriority(cards), [cards])
const incompleteCards = useMemo(() =>
sortedCards.filter((card: CardState) => !card.isCompleted),
[sortedCards]
)
const currentCard = incompleteCards[0] // 總是選擇優先級最高的未完成卡片
// localStorage進度保存和載入
useEffect(() => {
// 載入保存的進度
const savedProgress = localStorage.getItem('review-progress')
if (savedProgress) {
try {
const parsed = JSON.parse(savedProgress)
const saveTime = new Date(parsed.timestamp)
const now = new Date()
const isToday = saveTime.toDateString() === now.toDateString()
if (isToday && parsed.cards) {
dispatch({
type: 'LOAD_PROGRESS',
payload: {
cards: parsed.cards,
score: parsed.score || { correct: 0, total: 0 },
isComplete: parsed.isComplete || false
}
})
console.log('📖 載入保存的複習進度')
}
} catch (error) {
console.warn('進度載入失敗:', error)
localStorage.removeItem('review-progress')
}
}
}, [])
// 保存進度到localStorage
const saveProgress = () => {
const progress = {
cards,
score,
isComplete,
timestamp: new Date().toISOString()
}
localStorage.setItem('review-progress', JSON.stringify(progress))
console.log('💾 進度已保存')
}
// 處理答題 - 使用 dispatch 統一管理
const handleAnswer = (confidence: number) => {
if (!currentCard) return
dispatch({
type: 'ANSWER_CARD',
payload: { cardId: currentCard.id, confidence }
})
// 保存進度
setTimeout(() => saveProgress(), 100) // 延遲一點確保狀態更新
}
// 處理跳過 - 使用 dispatch 統一管理
const handleSkip = () => {
if (!currentCard) return
dispatch({
type: 'SKIP_CARD',
payload: { cardId: currentCard.id }
})
// 保存進度
setTimeout(() => saveProgress(), 100) // 延遲一點確保狀態更新
}
// 重新開始 - 重置所有狀態
const handleRestart = () => {
dispatch({ type: 'RESTART' })
localStorage.removeItem('review-progress') // 清除保存的進度
console.log('🔄 複習進度已重置')
} }
return { return {
// 狀態 (直接從 Store 映射) // 狀態
currentCard: store.currentCard, cards,
dueCards: store.dueCards, score,
currentCardIndex: store.currentCardIndex, isComplete,
isLoadingCard: store.isLoading, currentCard,
mode: store.mode, sortedCards,
isAutoSelecting: store.isAutoSelecting,
showNoDueCards: store.showNoDueCards,
showComplete: store.showComplete,
// 操作 (Store 方法的直接映射 + 業務邏輯) // 動作
loadNextCard, handleAnswer,
setCurrentCard: store.setCurrentCard, handleSkip,
setCurrentCardIndex: store.setCurrentCardIndex, handleRestart
setMode: store.setMode,
setAutoSelecting: store.setAutoSelecting,
setShowNoDueCards: store.setShowNoDueCards,
setShowComplete: store.setShowComplete,
resetSession: store.resetSession
} }
} }

View File

@ -1,5 +1,5 @@
// 模擬真實API數據結構 // 模擬真實API數據結構
import apiSeeds from './components/api_seeds.json' import apiSeeds from './api_seeds.json'
// API響應接口 (匹配真實API結構 + 同義詞擴展) // API響應接口 (匹配真實API結構 + 同義詞擴展)
export interface ApiFlashcard { export interface ApiFlashcard {

View File

@ -6,10 +6,10 @@ import { ReviewRunner } from '@/components/review/core/ReviewRunner'
import { ProgressTracker } from '@/components/review/ui/ProgressTracker' import { ProgressTracker } from '@/components/review/ui/ProgressTracker'
// Store imports // Store imports
import { useReviewSessionStore } from '@/store/review/useReviewSessionStore' import { useReviewSessionStore } from '../../store/review/useReviewSessionStore'
import { useTestQueueStore } from '@/store/review/useTestQueueStore' import { useTestQueueStore } from '@/archive/store/review/useTestQueueStore'
import { useTestResultStore } from '@/store/review/useTestResultStore' import { useTestResultStore } from '@/archive/store/review/useTestResultStore'
import { useReviewDataStore } from '@/store/review/useReviewDataStore' import { useReviewDataStore } from '@/archive/store/review/useReviewDataStore'
import exampleData from './example-data.json' import exampleData from './example-data.json'
// 動態測試資料集配置 // 動態測試資料集配置

View File

@ -13,12 +13,12 @@ import { LoadingStates } from '@/components/review/ui/LoadingStates'
import { ReviewRunner } from '@/components/review/core/ReviewRunner' import { ReviewRunner } from '@/components/review/core/ReviewRunner'
// 狀態管理 // 狀態管理
import { useReviewSessionStore } from '@/store/review/useReviewSessionStore' import { useReviewSessionStore } from '../../store/review/useReviewSessionStore'
import { useTestQueueStore } from '@/store/review/useTestQueueStore' import { useTestQueueStore } from '@/archive/store/review/useTestQueueStore'
import { useTestResultStore } from '@/store/review/useTestResultStore' import { useTestResultStore } from '@/archive/store/review/useTestResultStore'
import { useReviewDataStore } from '@/store/review/useReviewDataStore' import { useReviewDataStore } from '@/archive/store/review/useReviewDataStore'
import { useReviewUIStore } from '@/store/review/useReviewUIStore' import { useReviewUIStore } from '@/archive/store/review/useReviewUIStore'
import { ReviewService } from '@/lib/services/review/reviewService' import { ReviewService } from '@/archive/lib/services/review/reviewService'
export default function LearnPage() { export default function LearnPage() {
const router = useRouter() const router = useRouter()

View File

@ -1,5 +1,5 @@
import React, { memo, useCallback, useMemo } from 'react' import React, { memo, useCallback, useMemo } from 'react'
import { useTestQueueStore } from '@/store/review/useTestQueueStore' import { useTestQueueStore } from '@/archive/store/review/useTestQueueStore'
/** /**
* *

View File

@ -1,8 +1,8 @@
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback } from 'react'
import { useReviewSessionStore } from '@/store/review/useReviewSessionStore' import { useReviewSessionStore } from '@/archive/store/review/useReviewSessionStore'
import { useTestQueueStore } from '@/store/review/useTestQueueStore' import { useTestQueueStore } from '@/archive/store/review/useTestQueueStore'
import { useTestResultStore } from '@/store/review/useTestResultStore' import { useTestResultStore } from '@/archive/store/review/useTestResultStore'
import { useReviewUIStore } from '@/store/review/useReviewUIStore' import { useReviewUIStore } from '@/archive/store/review/useReviewUIStore'
import { SmartNavigationController } from './NavigationController' import { SmartNavigationController } from './NavigationController'
import { ProgressBar } from '../ui/ProgressBar' import { ProgressBar } from '../ui/ProgressBar'
import { import {

View File

@ -1,5 +1,5 @@
import React from 'react' import React from 'react'
import { TestItem } from '@/store/review/useTestQueueStore' import { TestItem } from '@/archive/store/review/useTestQueueStore'
import { TestStatusIndicator, TestStats, TestProgressBar, TestStatusList } from './TestStatusIndicator' import { TestStatusIndicator, TestStats, TestProgressBar, TestStatusList } from './TestStatusIndicator'
interface TaskListModalProps { interface TaskListModalProps {

View File

@ -1,5 +1,5 @@
import React, { memo } from 'react' import React, { memo } from 'react'
import { TestItem } from '@/store/review/useTestQueueStore' import { TestItem } from '@/archive/store/review/useTestQueueStore'
/** /**
* *

View File

@ -0,0 +1,84 @@
/**
* Hook - Store
* 便 Store Store
*/
import { useReviewSessionStore } from '../../../note/archive/store/review/useReviewSessionStore'
import { flashcardsService } from '@/lib/services/flashcards'
import type { ExtendedFlashcard, ReviewMode } from '@/archive/lib/types/review'
interface UseReviewSessionReturn {
// 狀態 (從 Store 直接取得)
currentCard: ExtendedFlashcard | null
dueCards: ExtendedFlashcard[]
currentCardIndex: number
isLoadingCard: boolean
mode: ReviewMode
isAutoSelecting: boolean
showNoDueCards: boolean
showComplete: boolean
// 操作 (Store 的包裝方法)
loadNextCard: () => Promise<void>
setCurrentCard: (card: ExtendedFlashcard | null) => void
setCurrentCardIndex: (index: number) => void
setMode: (mode: ReviewMode) => void
setAutoSelecting: (auto: boolean) => void
setShowNoDueCards: (show: boolean) => void
setShowComplete: (show: boolean) => void
resetSession: () => void
}
export function useReviewSession(): UseReviewSessionReturn {
// 從 Store 取得狀態和操作
const store = useReviewSessionStore()
// 載入卡片的業務邏輯 (唯一的 Hook 專有邏輯)
const loadNextCard = async () => {
try {
store.setLoading(true)
store.setError(null)
const result = await flashcardsService.getDueFlashcards(50)
if (result.success && result.data && result.data.length > 0) {
store.setDueCards(result.data)
store.setCurrentCard(result.data[0])
store.setCurrentCardIndex(0)
store.setShowNoDueCards(false)
} else {
store.setShowNoDueCards(true)
store.setCurrentCard(null)
store.setDueCards([])
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '載入卡片失敗'
store.setError(errorMessage)
store.setShowNoDueCards(true)
} finally {
store.setLoading(false)
}
}
return {
// 狀態 (直接從 Store 映射)
currentCard: store.currentCard,
dueCards: store.dueCards,
currentCardIndex: store.currentCardIndex,
isLoadingCard: store.isLoading,
mode: store.mode,
isAutoSelecting: store.isAutoSelecting,
showNoDueCards: store.showNoDueCards,
showComplete: store.showComplete,
// 操作 (Store 方法的直接映射 + 業務邏輯)
loadNextCard,
setCurrentCard: store.setCurrentCard,
setCurrentCardIndex: store.setCurrentCardIndex,
setMode: store.setMode,
setAutoSelecting: store.setAutoSelecting,
setShowNoDueCards: store.setShowNoDueCards,
setShowComplete: store.setShowComplete,
resetSession: store.resetSession
}
}

View File

@ -1,5 +1,5 @@
// Mock 數據用於複習功能測試 // Mock 數據用於複習功能測試
import { ExtendedFlashcard } from '@/lib/types/review' import { ExtendedFlashcard } from '@/archive/lib/types/review'
export const mockDueCards: ExtendedFlashcard[] = [ export const mockDueCards: ExtendedFlashcard[] = [
{ {

View File

@ -1,7 +1,7 @@
import { flashcardsService } from '@/lib/services/flashcards' import { flashcardsService } from '@/lib/services/flashcards'
import { ExtendedFlashcard } from '@/lib/types/review' import { ExtendedFlashcard } from '@/archive/lib/types/review'
import { TestItem } from '@/store/review/useTestQueueStore' import { TestItem } from '@/archive/store/review/useTestQueueStore'
import { isTestMode, getMockCompletedTests } from '@/lib/mock/reviewMockData' import { isTestMode, getMockCompletedTests } from '@/archive/lib/mock/reviewMockData'
// 複習會話服務 // 複習會話服務
export class ReviewService { export class ReviewService {

View File

@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, vi } from 'vitest' import { describe, it, expect, beforeEach, vi } from 'vitest'
import { useReviewDataStore } from '../useReviewDataStore' import { useReviewDataStore } from '../useReviewDataStore'
import { mockDueCards } from '@/lib/mock/reviewMockData' import { mockDueCards } from '@/archive/lib/mock/reviewMockData'
// Mock flashcardsService // Mock flashcardsService
vi.mock('@/lib/services/flashcards', () => ({ vi.mock('@/lib/services/flashcards', () => ({

View File

@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, vi } from 'vitest' import { describe, it, expect, beforeEach, vi } from 'vitest'
import { useTestQueueStore, TestItem, ReviewMode } from '../useTestQueueStore' import { useTestQueueStore, TestItem, ReviewMode } from '../useTestQueueStore'
import { mockDueCards } from '@/lib/mock/reviewMockData' import { mockDueCards } from '@/archive/lib/mock/reviewMockData'
// Mock dependencies // Mock dependencies
vi.mock('@/lib/utils/cefrUtils', () => ({ vi.mock('@/lib/utils/cefrUtils', () => ({

View File

@ -1,9 +1,9 @@
import { create } from 'zustand' import { create } from 'zustand'
import { subscribeWithSelector } from 'zustand/middleware' import { subscribeWithSelector } from 'zustand/middleware'
import { flashcardsService } from '@/lib/services/flashcards' import { flashcardsService } from '@/lib/services/flashcards'
import { ExtendedFlashcard } from '@/lib/types/review' import { ExtendedFlashcard } from '@/archive/lib/types/review'
import { isTestMode, getMockDueCards } from '@/lib/mock/reviewMockData' import { isTestMode, getMockDueCards } from '@/archive/lib/mock/reviewMockData'
import { ReviewService } from '@/lib/services/review/reviewService' import { ReviewService } from '@/archive/lib/services/review/reviewService'
// 數據狀態接口 // 數據狀態接口
interface ReviewDataState { interface ReviewDataState {

View File

@ -1,6 +1,6 @@
import { create } from 'zustand' import { create } from 'zustand'
import { subscribeWithSelector } from 'zustand/middleware' import { subscribeWithSelector } from 'zustand/middleware'
import type { ReviewSessionStore } from '@/lib/types/review' import type { ReviewSessionStore } from '@/archive/lib/types/review'
export const useReviewSessionStore = create<ReviewSessionStore>()( export const useReviewSessionStore = create<ReviewSessionStore>()(
subscribeWithSelector((set, get) => ({ subscribeWithSelector((set, get) => ({

View File

@ -1,7 +1,7 @@
import { create } from 'zustand' import { create } from 'zustand'
import { subscribeWithSelector } from 'zustand/middleware' import { subscribeWithSelector } from 'zustand/middleware'
import { getReviewTypesByCEFR } from '@/lib/utils/cefrUtils' import { getReviewTypesByCEFR } from '@/lib/utils/cefrUtils'
import { isTestMode, getTestModeReviewTypes } from '@/lib/mock/reviewMockData' import { isTestMode, getTestModeReviewTypes } from '@/archive/lib/mock/reviewMockData'
// 複習模式類型 // 複習模式類型
export type ReviewMode = 'flip-memory' | 'vocab-choice' | 'vocab-listening' | 'sentence-listening' | 'sentence-fill' | 'sentence-reorder' | 'sentence-speaking' export type ReviewMode = 'flip-memory' | 'vocab-choice' | 'vocab-listening' | 'sentence-listening' | 'sentence-fill' | 'sentence-reorder' | 'sentence-speaking'

View File

@ -2,7 +2,7 @@ import { create } from 'zustand'
import { subscribeWithSelector } from 'zustand/middleware' import { subscribeWithSelector } from 'zustand/middleware'
import { flashcardsService } from '@/lib/services/flashcards' import { flashcardsService } from '@/lib/services/flashcards'
import { ReviewMode } from './useTestQueueStore' import { ReviewMode } from './useTestQueueStore'
import { isTestMode } from '@/lib/mock/reviewMockData' import { isTestMode } from '@/archive/lib/mock/reviewMockData'
// 測試結果狀態接口 // 測試結果狀態接口
interface TestResultState { interface TestResultState {