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:
parent
914c981c4b
commit
04def4bb85
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 管理複習狀態
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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'
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { CardState } from '../data'
|
import { CardState } from '@/lib/data/reviewSimpleData'
|
||||||
|
|
||||||
interface SimpleProgressProps {
|
interface SimpleProgressProps {
|
||||||
current: number
|
current: number
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -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'
|
||||||
|
|
||||||
// 動態測試資料集配置
|
// 動態測試資料集配置
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 智能導航控制器
|
* 智能導航控制器
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -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'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 測驗狀態指示器
|
* 測驗狀態指示器
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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[] = [
|
||||||
{
|
{
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -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', () => ({
|
||||||
|
|
@ -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', () => ({
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -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) => ({
|
||||||
|
|
@ -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'
|
||||||
|
|
@ -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 {
|
||||||
Loading…
Reference in New Issue