336 lines
9.7 KiB
TypeScript
336 lines
9.7 KiB
TypeScript
import { create } from 'zustand'
|
|
import { subscribeWithSelector } from 'zustand/middleware'
|
|
import { flashcardsService, type Flashcard } from '@/lib/services/flashcards'
|
|
import { getReviewTypesByCEFR } from '@/lib/utils/cefrUtils'
|
|
|
|
// 複習模式類型
|
|
export type ReviewMode = 'flip-memory' | 'vocab-choice' | 'vocab-listening' | 'sentence-listening' | 'sentence-fill' | 'sentence-reorder' | 'sentence-speaking'
|
|
|
|
// 擴展的詞卡接口
|
|
export interface ExtendedFlashcard extends Omit<Flashcard, 'nextReviewDate'> {
|
|
nextReviewDate?: string
|
|
currentInterval?: number
|
|
isOverdue?: boolean
|
|
overdueDays?: number
|
|
baseMasteryLevel?: number
|
|
lastReviewDate?: string
|
|
synonyms?: string[]
|
|
exampleImage?: string
|
|
}
|
|
|
|
// 測驗項目接口
|
|
export interface TestItem {
|
|
id: string
|
|
cardId: string
|
|
word: string
|
|
testType: ReviewMode
|
|
testName: string
|
|
isCompleted: boolean
|
|
isCurrent: boolean
|
|
order: number
|
|
}
|
|
|
|
// 學習會話狀態
|
|
interface LearnState {
|
|
// 核心狀態
|
|
mounted: boolean
|
|
isLoading: boolean
|
|
currentCard: ExtendedFlashcard | null
|
|
dueCards: ExtendedFlashcard[]
|
|
currentCardIndex: number
|
|
|
|
// 測驗狀態
|
|
currentMode: ReviewMode
|
|
testItems: TestItem[]
|
|
currentTestIndex: number
|
|
completedTests: number
|
|
totalTests: number
|
|
|
|
// 進度狀態
|
|
score: { correct: number; total: number }
|
|
|
|
// UI狀態
|
|
showComplete: boolean
|
|
showNoDueCards: boolean
|
|
|
|
// 錯誤狀態
|
|
error: string | null
|
|
|
|
// Actions
|
|
setMounted: (mounted: boolean) => void
|
|
setLoading: (loading: boolean) => void
|
|
loadDueCards: () => Promise<void>
|
|
initializeTestQueue: (completedTests: any[]) => void
|
|
goToNextTest: () => void
|
|
recordTestResult: (isCorrect: boolean, userAnswer?: string, confidenceLevel?: number) => Promise<void>
|
|
skipCurrentTest: () => void
|
|
resetSession: () => void
|
|
updateScore: (isCorrect: boolean) => void
|
|
setError: (error: string | null) => void
|
|
}
|
|
|
|
export const useLearnStore = create<LearnState>()(
|
|
subscribeWithSelector((set, get) => ({
|
|
// 初始狀態
|
|
mounted: false,
|
|
isLoading: false,
|
|
currentCard: null,
|
|
dueCards: [],
|
|
currentCardIndex: 0,
|
|
|
|
currentMode: 'flip-memory',
|
|
testItems: [],
|
|
currentTestIndex: 0,
|
|
completedTests: 0,
|
|
totalTests: 0,
|
|
|
|
score: { correct: 0, total: 0 },
|
|
|
|
showComplete: false,
|
|
showNoDueCards: false,
|
|
|
|
error: null,
|
|
|
|
// Actions
|
|
setMounted: (mounted) => set({ mounted }),
|
|
|
|
setLoading: (loading) => set({ isLoading: loading }),
|
|
|
|
loadDueCards: async () => {
|
|
try {
|
|
set({ isLoading: true, error: null })
|
|
console.log('🔍 開始載入到期詞卡...')
|
|
|
|
const apiResult = await flashcardsService.getDueFlashcards(50)
|
|
console.log('📡 API回應結果:', apiResult)
|
|
|
|
if (apiResult.success && apiResult.data && apiResult.data.length > 0) {
|
|
const cards = apiResult.data
|
|
console.log('✅ 載入後端API數據成功:', cards.length, '張詞卡')
|
|
|
|
set({
|
|
dueCards: cards,
|
|
currentCard: cards[0],
|
|
currentCardIndex: 0,
|
|
showNoDueCards: false,
|
|
showComplete: false
|
|
})
|
|
} else {
|
|
console.log('❌ 沒有到期詞卡')
|
|
set({
|
|
dueCards: [],
|
|
currentCard: null,
|
|
showNoDueCards: true,
|
|
showComplete: false
|
|
})
|
|
}
|
|
} catch (error) {
|
|
console.error('💥 載入到期詞卡失敗:', error)
|
|
set({
|
|
error: '載入詞卡失敗',
|
|
dueCards: [],
|
|
currentCard: null,
|
|
showNoDueCards: true
|
|
})
|
|
} finally {
|
|
set({ isLoading: false })
|
|
}
|
|
},
|
|
|
|
initializeTestQueue: (completedTests = []) => {
|
|
const { dueCards } = get()
|
|
const userCEFRLevel = localStorage.getItem('userEnglishLevel') || 'A2'
|
|
let remainingTestItems: TestItem[] = []
|
|
let order = 1
|
|
|
|
dueCards.forEach(card => {
|
|
const wordCEFRLevel = card.difficultyLevel || 'A2'
|
|
const allTestTypes = getReviewTypesByCEFR(userCEFRLevel, wordCEFRLevel)
|
|
|
|
const completedTestTypes = completedTests
|
|
.filter(ct => ct.flashcardId === card.id)
|
|
.map(ct => ct.testType)
|
|
|
|
const remainingTestTypes = allTestTypes.filter(testType =>
|
|
!completedTestTypes.includes(testType)
|
|
)
|
|
|
|
console.log(`🎯 詞卡 ${card.word}: 總共${allTestTypes.length}個測驗, 已完成${completedTestTypes.length}個, 剩餘${remainingTestTypes.length}個`)
|
|
|
|
remainingTestTypes.forEach(testType => {
|
|
remainingTestItems.push({
|
|
id: `${card.id}-${testType}`,
|
|
cardId: card.id,
|
|
word: card.word,
|
|
testType: testType as ReviewMode,
|
|
testName: getTestTypeName(testType),
|
|
isCompleted: false,
|
|
isCurrent: false,
|
|
order
|
|
})
|
|
order++
|
|
})
|
|
})
|
|
|
|
if (remainingTestItems.length === 0) {
|
|
console.log('🎉 所有測驗都已完成!')
|
|
set({ showComplete: true })
|
|
return
|
|
}
|
|
|
|
// 標記第一個測驗為當前
|
|
remainingTestItems[0].isCurrent = true
|
|
|
|
set({
|
|
testItems: remainingTestItems,
|
|
totalTests: remainingTestItems.length,
|
|
currentTestIndex: 0,
|
|
completedTests: 0,
|
|
currentMode: remainingTestItems[0].testType
|
|
})
|
|
|
|
console.log('📝 剩餘測驗項目:', remainingTestItems.length, '個')
|
|
},
|
|
|
|
goToNextTest: () => {
|
|
const { testItems, currentTestIndex } = get()
|
|
|
|
if (currentTestIndex + 1 < testItems.length) {
|
|
const nextIndex = currentTestIndex + 1
|
|
const updatedTestItems = testItems.map((item, index) => ({
|
|
...item,
|
|
isCurrent: index === nextIndex
|
|
}))
|
|
|
|
const nextTestItem = updatedTestItems[nextIndex]
|
|
const { dueCards } = get()
|
|
const nextCard = dueCards.find(c => c.id === nextTestItem.cardId)
|
|
|
|
set({
|
|
testItems: updatedTestItems,
|
|
currentTestIndex: nextIndex,
|
|
currentMode: nextTestItem.testType,
|
|
currentCard: nextCard || null
|
|
})
|
|
|
|
console.log(`🔄 載入下一個測驗: ${nextTestItem.word} - ${nextTestItem.testType}`)
|
|
} else {
|
|
console.log('🎉 所有測驗完成!')
|
|
set({ showComplete: true })
|
|
}
|
|
},
|
|
|
|
recordTestResult: async (isCorrect, userAnswer, confidenceLevel) => {
|
|
const { testItems, currentTestIndex } = get()
|
|
const currentTestItem = testItems[currentTestIndex]
|
|
|
|
if (!currentTestItem) return
|
|
|
|
try {
|
|
console.log('🔄 開始記錄測驗結果...', {
|
|
flashcardId: currentTestItem.cardId,
|
|
testType: currentTestItem.testType,
|
|
isCorrect
|
|
})
|
|
|
|
const result = await flashcardsService.recordTestCompletion({
|
|
flashcardId: currentTestItem.cardId,
|
|
testType: currentTestItem.testType,
|
|
isCorrect,
|
|
userAnswer,
|
|
confidenceLevel,
|
|
responseTimeMs: 2000
|
|
})
|
|
|
|
if (result.success) {
|
|
console.log('✅ 測驗結果已記錄')
|
|
|
|
// 更新本地狀態
|
|
const updatedTestItems = testItems.map((item, index) =>
|
|
index === currentTestIndex
|
|
? { ...item, isCompleted: true, isCurrent: false }
|
|
: item
|
|
)
|
|
|
|
set({
|
|
testItems: updatedTestItems,
|
|
completedTests: get().completedTests + 1
|
|
})
|
|
|
|
// 延遲進入下一個測驗
|
|
setTimeout(() => {
|
|
get().goToNextTest()
|
|
}, 1500)
|
|
} else {
|
|
console.error('❌ 記錄測驗結果失敗:', result.error)
|
|
set({ error: '記錄測驗結果失敗' })
|
|
}
|
|
} catch (error) {
|
|
console.error('💥 記錄測驗結果異常:', error)
|
|
set({ error: '記錄測驗結果異常' })
|
|
}
|
|
},
|
|
|
|
skipCurrentTest: () => {
|
|
const { testItems, currentTestIndex } = get()
|
|
const currentTest = testItems[currentTestIndex]
|
|
|
|
if (!currentTest) return
|
|
|
|
// 將當前測驗移到隊列最後
|
|
const newItems = [...testItems]
|
|
newItems.splice(currentTestIndex, 1)
|
|
newItems.push({ ...currentTest, isCurrent: false })
|
|
|
|
// 標記新的當前項目
|
|
if (newItems[currentTestIndex]) {
|
|
newItems[currentTestIndex].isCurrent = true
|
|
}
|
|
|
|
set({ testItems: newItems })
|
|
console.log(`⏭️ 跳過測驗: ${currentTest.word} - ${currentTest.testType}`)
|
|
},
|
|
|
|
updateScore: (isCorrect) => {
|
|
set(state => ({
|
|
score: {
|
|
correct: isCorrect ? state.score.correct + 1 : state.score.correct,
|
|
total: state.score.total + 1
|
|
}
|
|
}))
|
|
},
|
|
|
|
resetSession: () => {
|
|
set({
|
|
currentCard: null,
|
|
dueCards: [],
|
|
currentCardIndex: 0,
|
|
currentMode: 'flip-memory',
|
|
testItems: [],
|
|
currentTestIndex: 0,
|
|
completedTests: 0,
|
|
totalTests: 0,
|
|
score: { correct: 0, total: 0 },
|
|
showComplete: false,
|
|
showNoDueCards: false,
|
|
error: null
|
|
})
|
|
},
|
|
|
|
setError: (error) => set({ error })
|
|
}))
|
|
)
|
|
|
|
// 工具函數
|
|
function getTestTypeName(testType: string): string {
|
|
const names = {
|
|
'flip-memory': '翻卡記憶',
|
|
'vocab-choice': '詞彙選擇',
|
|
'sentence-fill': '例句填空',
|
|
'sentence-reorder': '例句重組',
|
|
'vocab-listening': '詞彙聽力',
|
|
'sentence-listening': '例句聽力',
|
|
'sentence-speaking': '例句口說'
|
|
}
|
|
return names[testType as keyof typeof names] || testType
|
|
} |