480 lines
15 KiB
TypeScript
480 lines
15 KiB
TypeScript
import { useReducer, useEffect, useMemo, useState } from 'react'
|
||
import { flashcardsService, Flashcard } from '@/lib/services/flashcards'
|
||
|
||
// 重新定義所需的介面,確保與 reviewSimpleData.CardState 完全兼容
|
||
interface CardState extends Flashcard {
|
||
skipCount: number
|
||
wrongCount: number
|
||
isCompleted: boolean
|
||
originalOrder: number
|
||
synonyms: string[] // 改為必需的數組
|
||
difficultyLevelNumeric: number // 必需屬性
|
||
exampleTranslation: string // 覆蓋 Flashcard 的可選定義,改為必需
|
||
updatedAt: string // 覆蓋 Flashcard 的可選定義,改為必需
|
||
primaryImageUrl: string | null // 確保類型匹配(null 而非 undefined)
|
||
}
|
||
|
||
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 || '', // 確保為 string,不是 undefined
|
||
updatedAt: card.updatedAt || card.createdAt, // 確保 updatedAt 為 string
|
||
primaryImageUrl: card.primaryImageUrl || null, // 確保為 null 而非 undefined
|
||
skipCount: 0,
|
||
wrongCount: 0,
|
||
isCompleted: false,
|
||
originalOrder: order / 2, // 原始詞卡的順序
|
||
synonyms: [], // 確保為空數組而非 undefined
|
||
difficultyLevelNumeric: card.masteryLevel || 1 // 使用 masteryLevel 或預設值 1
|
||
}
|
||
|
||
// 為每張詞卡生成兩種測驗模式
|
||
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 : '載入詞卡失敗' }
|
||
})
|
||
}
|
||
}
|
||
|
||
// 清除可能的舊快取,確保始終從後端載入最新資料
|
||
localStorage.removeItem('review-linear-progress')
|
||
|
||
// 載入新的詞卡資料
|
||
loadFlashcards()
|
||
}, [])
|
||
|
||
// 監聽 pendingWordSubmission,自動提交詞彙完成
|
||
useEffect(() => {
|
||
if (pendingWordSubmission && !submittingWords.has(pendingWordSubmission)) {
|
||
console.log('🔄 監測到詞彙完成,準備提交:', pendingWordSubmission)
|
||
submitWordCompletion(pendingWordSubmission)
|
||
}
|
||
}, [pendingWordSubmission])
|
||
|
||
// 保存進度到localStorage (已停用以確保資料即時性)
|
||
const saveProgress = () => {
|
||
// 暫時停用快取機制,確保始終從後端獲取最新資料
|
||
// localStorage.removeItem('review-linear-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' })
|
||
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
|
||
}
|
||
} |