dramaling-vocab-learning/frontend/hooks/review/useReviewSession.ts

509 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import { useReducer, useEffect, useMemo, useState } from 'react'
import { flashcardsService, Flashcard } from '@/lib/services/flashcards'
// 重新定義所需的介面
interface CardState extends Flashcard {
skipCount: number
wrongCount: number
isCompleted: boolean
originalOrder: number
synonyms?: string[]
}
interface QuizItem {
id: string
cardId: string
cardData: CardState
quizType: 'flip-card' | 'vocab-choice'
order: number
isCompleted: boolean
wrongCount: number
skipCount: number
}
interface ReviewState {
quizItems: QuizItem[]
score: { correct: number; total: number }
isComplete: boolean
flashcards: Flashcard[]
isLoading: boolean
error: string | null
pendingWordSubmission: string | null // 等待提交的詞彙ID
submittingWords: Set<string> // 正在提交的詞彙ID集合
}
type ReviewAction =
| { type: 'LOAD_PROGRESS'; payload: ReviewState }
| { type: 'ANSWER_TEST_ITEM'; payload: { quizItemId: string; confidence: number } }
| { type: 'SKIP_TEST_ITEM'; payload: { quizItemId: string } }
| { type: 'RESTART' }
| { type: 'LOAD_FLASHCARDS_START' }
| { type: 'LOAD_FLASHCARDS_SUCCESS'; payload: { flashcards: Flashcard[]; quizItems: QuizItem[] } }
| { type: 'LOAD_FLASHCARDS_ERROR'; payload: { error: string } }
| { type: 'WORD_SUBMIT_START'; payload: { cardId: string } }
| { type: 'WORD_SUBMIT_SUCCESS'; payload: { cardId: string; nextReviewDate: string } }
| { type: 'WORD_SUBMIT_ERROR'; payload: { cardId: string; error: string } }
// 工具函數
const sortQuizItemsByPriority = (quizItems: QuizItem[]): QuizItem[] => {
return quizItems.sort((a, b) => {
if (a.isCompleted && !b.isCompleted) return 1
if (!a.isCompleted && b.isCompleted) return -1
const aDelayScore = a.skipCount + a.wrongCount
const bDelayScore = b.skipCount + b.wrongCount
if (aDelayScore !== bDelayScore) {
return aDelayScore - bDelayScore
}
return a.order - b.order
})
}
const generateVocabOptions = (correctWord: string, allCards: CardState[]): string[] => {
const allWords = allCards.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 checkWordCompleteAndCorrect = (cardId: string, quizItems: QuizItem[]): boolean => {
const wordQuizItems = quizItems.filter(item => item.cardId === cardId)
// 必須有測驗項目
if (wordQuizItems.length === 0) return false
// 所有測驗項目都必須完成
const allCompleted = wordQuizItems.every(item => item.isCompleted)
// 所有測驗項目都必須沒有錯誤
const allCorrect = wordQuizItems.every(item => item.wrongCount === 0)
return allCompleted && allCorrect
}
// 從 Flashcard 生成 QuizItem 的函數
const generateQuizItemsFromFlashcards = (flashcards: Flashcard[]): QuizItem[] => {
const quizItems: QuizItem[] = []
let order = 0
flashcards.forEach((card) => {
// 轉換 Flashcard 為 CardState 格式
const cardState: CardState = {
...card,
exampleTranslation: card.exampleTranslation || '', // 確保 exampleTranslation 不為 undefined
skipCount: 0,
wrongCount: 0,
isCompleted: false,
originalOrder: order / 2, // 原始詞卡的順序
synonyms: []
}
// 為每張詞卡生成兩種測驗模式
quizItems.push(
{
id: `${card.id}-flip-card`,
cardId: card.id,
cardData: cardState,
quizType: 'flip-card',
order: order++,
isCompleted: false,
wrongCount: 0,
skipCount: 0
},
{
id: `${card.id}-vocab-choice`,
cardId: card.id,
cardData: cardState,
quizType: 'vocab-choice',
order: order++,
isCompleted: false,
wrongCount: 0,
skipCount: 0
}
)
})
return quizItems
}
// 內部測驗項目更新函數
const updateQuizItem = (
quizItems: QuizItem[],
quizItemId: string,
updates: Partial<QuizItem>
): QuizItem[] => {
return quizItems.map((item) =>
item.id === quizItemId
? { ...item, ...updates }
: item
)
}
const reviewReducer = (state: ReviewState, action: ReviewAction): ReviewState => {
switch (action.type) {
case 'LOAD_PROGRESS':
return action.payload
case 'LOAD_FLASHCARDS_START':
return {
...state,
isLoading: true,
error: null
}
case 'LOAD_FLASHCARDS_SUCCESS':
return {
...state,
isLoading: false,
error: null,
flashcards: action.payload.flashcards,
quizItems: action.payload.quizItems
}
case 'LOAD_FLASHCARDS_ERROR':
return {
...state,
isLoading: false,
error: action.payload.error
}
case 'ANSWER_TEST_ITEM': {
const { quizItemId, confidence } = action.payload
const isCorrect = confidence >= 1 // 修正:一般(1分)以上都算答對
const quizItem = state.quizItems.find(item => item.id === quizItemId)
if (!quizItem) return state
// 修正:只有答對才標記為完成,答錯只增加錯誤次數
const updatedQuizItems = updateQuizItem(state.quizItems, quizItemId,
isCorrect
? { isCompleted: true } // 答對:標記完成
: { wrongCount: quizItem.wrongCount + 1 } // 答錯:只增加錯誤次數,不完成
)
const newScore = {
correct: state.score.correct + (isCorrect ? 1 : 0),
total: state.score.total + 1
}
const remainingQuizItems = updatedQuizItems.filter(item => !item.isCompleted)
const isComplete = remainingQuizItems.length === 0
// 檢查該詞彙是否完全掌握
const wordCompleteAndCorrect = checkWordCompleteAndCorrect(quizItem.cardId, updatedQuizItems)
return {
...state,
quizItems: updatedQuizItems,
score: newScore,
isComplete,
// 如果詞彙完全掌握,標記為等待提交
pendingWordSubmission: wordCompleteAndCorrect ? quizItem.cardId : state.pendingWordSubmission
}
}
case 'SKIP_TEST_ITEM': {
const { quizItemId } = action.payload
const quizItem = state.quizItems.find(item => item.id === quizItemId)
if (!quizItem) return state
const updatedQuizItems = updateQuizItem(state.quizItems, quizItemId, {
skipCount: quizItem.skipCount + 1
})
const remainingQuizItems = updatedQuizItems.filter(item => !item.isCompleted)
const isComplete = remainingQuizItems.length === 0
return {
...state,
quizItems: updatedQuizItems,
score: state.score,
isComplete
}
}
case 'RESTART':
const restartQuizItems = state.flashcards.length > 0
? generateQuizItemsFromFlashcards(state.flashcards)
: []
return {
...state,
quizItems: restartQuizItems,
score: { correct: 0, total: 0 },
isComplete: false,
pendingWordSubmission: null,
submittingWords: new Set<string>()
}
case 'WORD_SUBMIT_START': {
const { cardId } = action.payload
const newSubmittingWords = new Set(state.submittingWords)
newSubmittingWords.add(cardId)
return {
...state,
submittingWords: newSubmittingWords,
// 清除 pending 狀態,因為已經開始處理
pendingWordSubmission: state.pendingWordSubmission === cardId ? null : state.pendingWordSubmission
}
}
case 'WORD_SUBMIT_SUCCESS': {
const { cardId, nextReviewDate } = action.payload
const newSubmittingWords = new Set(state.submittingWords)
newSubmittingWords.delete(cardId)
return {
...state,
submittingWords: newSubmittingWords,
// 更新該詞彙的下次複習時間
flashcards: state.flashcards.map(card =>
card.id === cardId
? { ...card, nextReviewDate }
: card
)
}
}
case 'WORD_SUBMIT_ERROR': {
const { cardId } = action.payload
const newSubmittingWords = new Set(state.submittingWords)
newSubmittingWords.delete(cardId)
return {
...state,
submittingWords: newSubmittingWords
// 錯誤時不阻塞用戶繼續複習,只記錄 log
}
}
default:
return state
}
}
export function useReviewSession() {
// 使用 useReducer 統一狀態管理
const [state, dispatch] = useReducer(reviewReducer, {
quizItems: [],
score: { correct: 0, total: 0 },
isComplete: false,
flashcards: [],
isLoading: false,
error: null,
pendingWordSubmission: null,
submittingWords: new Set<string>()
})
const { quizItems, score, isComplete, flashcards, isLoading, error, pendingWordSubmission, submittingWords } = state
// 智能排序獲取當前測驗項目 - 使用 useMemo 優化性能
const sortedQuizItems = useMemo(() => sortQuizItemsByPriority(quizItems), [quizItems])
const incompleteQuizItems = useMemo(() =>
sortedQuizItems.filter((item: QuizItem) => !item.isCompleted),
[sortedQuizItems]
)
const currentQuizItem = incompleteQuizItems[0] // 總是選擇優先級最高的未完成測驗項目
const currentCard = currentQuizItem?.cardData // 當前詞卡數據
// 載入後端資料和進度
useEffect(() => {
const loadFlashcards = async () => {
dispatch({ type: 'LOAD_FLASHCARDS_START' })
try {
const response = await flashcardsService.getDueFlashcards(10)
if (response.success && response.data) {
const flashcards = response.data
const quizItems = generateQuizItemsFromFlashcards(flashcards)
dispatch({
type: 'LOAD_FLASHCARDS_SUCCESS',
payload: { flashcards, quizItems }
})
console.log('✅ 成功載入', flashcards.length, '張詞卡')
console.log('🎯 生成', quizItems.length, '個測驗項目')
} else {
dispatch({
type: 'LOAD_FLASHCARDS_ERROR',
payload: { error: response.error || '載入詞卡失敗' }
})
}
} catch (error) {
dispatch({
type: 'LOAD_FLASHCARDS_ERROR',
payload: { error: error instanceof Error ? error.message : '載入詞卡失敗' }
})
}
}
// 先嘗試載入保存的進度
const savedProgress = localStorage.getItem('review-linear-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.quizItems && parsed.flashcards) {
dispatch({
type: 'LOAD_PROGRESS',
payload: {
quizItems: parsed.quizItems,
score: parsed.score || { correct: 0, total: 0 },
isComplete: parsed.isComplete || false,
flashcards: parsed.flashcards,
isLoading: false,
error: null,
pendingWordSubmission: null,
submittingWords: new Set<string>()
}
})
console.log('📖 載入保存的線性複習進度')
return // 如果有保存的進度就不重新載入
}
} catch (error) {
console.warn('進度載入失敗:', error)
localStorage.removeItem('review-linear-progress')
}
}
// 載入新的詞卡資料
loadFlashcards()
}, [])
// 監聽 pendingWordSubmission自動提交詞彙完成
useEffect(() => {
if (pendingWordSubmission && !submittingWords.has(pendingWordSubmission)) {
console.log('🔄 監測到詞彙完成,準備提交:', pendingWordSubmission)
submitWordCompletion(pendingWordSubmission)
}
}, [pendingWordSubmission])
// 保存進度到localStorage
const saveProgress = () => {
const progress = {
quizItems,
score,
isComplete,
flashcards,
timestamp: new Date().toISOString()
}
localStorage.setItem('review-linear-progress', JSON.stringify(progress))
console.log('💾 線性進度已保存')
}
// 提交詞彙完成到後端(簡化版)
const submitWordCompletion = async (cardId: string) => {
dispatch({ type: 'WORD_SUBMIT_START', payload: { cardId } })
try {
console.log('🎯 詞彙完全掌握,提交到後端:', cardId)
// 使用簡化的 API只需要詞卡 ID
const result = await flashcardsService.markWordMastered(cardId)
if (result.success && result.data) {
dispatch({
type: 'WORD_SUBMIT_SUCCESS',
payload: {
cardId,
nextReviewDate: result.data.nextReviewDate
}
})
console.log('🎉 詞彙已掌握!複習時間已更新至:', result.data.nextReviewDate)
console.log('📅 間隔天數:', result.data.intervalDays, '天')
console.log('🏆 成功次數:', result.data.successCount)
} else {
throw new Error(result.error || '標記詞彙掌握失敗')
}
} catch (error) {
dispatch({
type: 'WORD_SUBMIT_ERROR',
payload: {
cardId,
error: error instanceof Error ? error.message : '標記詞彙掌握失敗'
}
})
console.warn('❌ 詞彙掌握標記失敗:', error)
// 不影響用戶繼續複習
}
}
// 處理測驗項目答題
const handleAnswer = (confidence: number) => {
if (!currentQuizItem) return
dispatch({
type: 'ANSWER_TEST_ITEM',
payload: { quizItemId: currentQuizItem.id, confidence }
})
// 保存進度
setTimeout(() => saveProgress(), 100)
}
// 處理測驗項目跳過
const handleSkip = () => {
if (!currentQuizItem) return
dispatch({
type: 'SKIP_TEST_ITEM',
payload: { quizItemId: currentQuizItem.id }
})
// 保存進度
setTimeout(() => saveProgress(), 100)
}
// 重新開始 - 重置所有狀態
const handleRestart = () => {
dispatch({ type: 'RESTART' })
localStorage.removeItem('review-linear-progress')
console.log('🔄 線性複習進度已重置')
}
// 生成詞彙選擇選項 (使用後端提供的 quizOptions)
const vocabOptions = useMemo(() => {
if (currentQuizItem?.quizType === 'vocab-choice' && currentCard) {
// 優先使用後端提供的 quizOptions
if (currentCard.quizOptions && currentCard.quizOptions.length > 0) {
// 將正確答案和混淆選項組合,並隨機排序
const allOptions = [currentCard.word, ...currentCard.quizOptions]
return allOptions.sort(() => Math.random() - 0.5)
}
// 後備方案:使用本地生成的選項
const cardStates = quizItems.map(item => item.cardData)
return generateVocabOptions(currentCard.word, cardStates)
}
return []
}, [currentQuizItem, currentCard, flashcards])
return {
// 狀態
quizItems,
score,
isComplete,
currentQuizItem,
currentCard,
vocabOptions,
sortedQuizItems,
isLoading,
error,
flashcards,
// 計算屬性
totalQuizItems: quizItems.length,
completedQuizItems: quizItems.filter(item => item.isCompleted).length,
// 動作
handleAnswer,
handleSkip,
handleRestart
}
}