diff --git a/frontend/app/learn-backup/README.md b/frontend/app/learn-backup/README.md new file mode 100644 index 0000000..12dcbdf --- /dev/null +++ b/frontend/app/learn-backup/README.md @@ -0,0 +1,34 @@ +# Learn 頁面備份說明 + +## 📅 備份日期 +2025-09-27 + +## 📋 備份檔案清單 + +### `page-v1-original.tsx` (2428 行, 94KB) +- **來源**: 原始 `page.tsx` +- **特徵**: 包含所有功能的龐大檔案 +- **問題**: 過於臃腫,難以維護 +- **功能**: 完整的複習系統,包含所有測驗類型 + +### `page-v2-smaller.tsx` (27KB) +- **來源**: 原始 `new-page.tsx` +- **特徵**: 較小版本,部分功能簡化 +- **狀態**: 開發中的版本 + +## 🎯 重構目標 +將原始的 2428 行巨型檔案重構為模組化架構: +- 主頁面 < 200 行 +- 功能拆分為多個 hooks 和組件 +- 提升可維護性和開發體驗 + +## 🔄 重構策略 +1. 保留所有現有功能 +2. 拆分狀態管理邏輯到自訂 hooks +3. 拆分 UI 組件 +4. 清理冗餘代碼 + +## ⚠️ 注意事項 +- 這些備份檔案包含完整的原始功能 +- 如果重構過程中遇到問題,可以參考這些檔案 +- 不要刪除此備份目錄 \ No newline at end of file diff --git a/frontend/app/learn-backup/page-v1-original.tsx b/frontend/app/learn-backup/page-v1-original.tsx new file mode 100644 index 0000000..daa1939 --- /dev/null +++ b/frontend/app/learn-backup/page-v1-original.tsx @@ -0,0 +1,2429 @@ +'use client' + +import { useState, useEffect, useRef, useLayoutEffect } from 'react' +import { useRouter } from 'next/navigation' +import { Navigation } from '@/components/Navigation' +import AudioPlayer from '@/components/AudioPlayer' +import VoiceRecorder from '@/components/VoiceRecorder' +import LearningComplete from '@/components/LearningComplete' +import ReviewTypeIndicator from '@/components/review/ReviewTypeIndicator' +import MasteryIndicator from '@/components/review/MasteryIndicator' +import { flashcardsService, type Flashcard } from '@/lib/services/flashcards' +import { calculateCurrentMastery, getReviewTypesByDifficulty } from '@/lib/utils/masteryCalculator' + +// 測驗項目接口 +interface TestItem { + id: string; // 唯一ID: cardId + testType + cardId: string; // 所屬詞卡ID + word: string; // 詞卡單字 + testType: string; // 測驗類型 (flip-memory, vocab-choice, etc.) + testName: string; // 測驗中文名稱 + isCompleted: boolean; // 是否已完成 + isCurrent: boolean; // 是否為當前測驗 + order: number; // 執行順序 (1-8) +} + +// 詞卡測驗分組接口 +interface CardTestGroup { + cardId: string; + word: string; + context: string; + tests: TestItem[]; +} + +// 擴展的Flashcard接口,包含智能複習需要的欄位 +interface ExtendedFlashcard extends Omit { + nextReviewDate?: string; // 下次復習日期 (可選) + currentInterval?: number; // 當前間隔天數 + isOverdue?: boolean; // 是否逾期 + overdueDays?: number; // 逾期天數 + baseMasteryLevel?: number; // 基礎熟悉度 + lastReviewDate?: string; // 最後復習日期 + synonyms?: string[]; // 同義詞 + exampleImage?: string; // 例句圖片 + // 注意:userLevel和wordLevel已移除,改用即時CEFR轉換 +} + +// 單個測驗結果接口 +interface TestResult { + testType: string; // 測驗類型 + isCorrect: boolean; // 是否正確 + userAnswer?: string; // 用戶答案 + confidenceLevel?: number; // 信心等級 (1-5, 用於flip-memory) + responseTimeMs: number; // 答題時間 + completedAt: Date; // 完成時間 +} + +// 詞卡複習會話接口 +interface CardReviewSession { + cardId: string; // 詞卡ID + word: string; // 詞卡單字 + plannedTests: string[]; // 預定的測驗類型列表 + completedTests: TestResult[]; // 已完成的測驗結果 + startedAt: Date; // 開始時間 + isCompleted: boolean; // 是否完成所有測驗 +} + +export default function LearnPage() { + const router = useRouter() + const [mounted, setMounted] = useState(false) + + // 智能複習狀態 + const [currentCard, setCurrentCard] = useState(null) + const [dueCards, setDueCards] = useState([]) + const [currentCardIndex, setCurrentCardIndex] = useState(0) + const [isLoadingCard, setIsLoadingCard] = useState(false) + + // 複習模式狀態 (系統自動選擇) + const [mode, setMode] = useState<'flip-memory' | 'vocab-choice' | 'vocab-listening' | 'sentence-listening' | 'sentence-fill' | 'sentence-reorder' | 'sentence-speaking'>('flip-memory') + const [isAutoSelecting, setIsAutoSelecting] = useState(true) + + // 答題狀態 + const [score, setScore] = useState({ correct: 0, total: 0 }) + const [selectedAnswer, setSelectedAnswer] = useState(null) + const [showResult, setShowResult] = useState(false) + const [fillAnswer, setFillAnswer] = useState('') + const [showHint, setShowHint] = useState(false) + const [isFlipped, setIsFlipped] = useState(false) + + // 測驗進度狀態 + const [totalTests, setTotalTests] = useState(0) // 所有測驗總數 + const [completedTests, setCompletedTests] = useState(0) // 已完成測驗數 + const [testItems, setTestItems] = useState([]) // 測驗項目列表 + const [currentTestItemIndex, setCurrentTestItemIndex] = useState(0) // 當前測驗項目索引 + + // 詞卡複習會話狀態 + const [cardReviewSessions, setCardReviewSessions] = useState>(new Map()) + const [currentCardSession, setCurrentCardSession] = useState(null) + const [completedCards, setCompletedCards] = useState(0) // 已完成復習的詞卡數 + + // UI狀態 + const [modalImage, setModalImage] = useState(null) + const [showReportModal, setShowReportModal] = useState(false) + const [reportReason, setReportReason] = useState('') + const [reportingCard, setReportingCard] = useState(null) + const [showComplete, setShowComplete] = useState(false) + const [showNoDueCards, setShowNoDueCards] = useState(false) + const [showTaskListModal, setShowTaskListModal] = useState(false) + const [cardHeight, setCardHeight] = useState(400) + + // 題型特定狀態 + const [quizOptions, setQuizOptions] = useState([]) + const [sentenceOptions, setSentenceOptions] = useState([]) + + // 例句重組狀態 + const [shuffledWords, setShuffledWords] = useState([]) + const [arrangedWords, setArrangedWords] = useState([]) + const [reorderResult, setReorderResult] = useState(null) + + // Refs for measuring card content heights + const cardFrontRef = useRef(null) + const cardBackRef = useRef(null) + const cardContainerRef = useRef(null) + + // Calculate optimal card height based on content (only when card changes) + const calculateCardHeight = () => { + if (!cardFrontRef.current || !cardBackRef.current) return 400; + + // Get the scroll heights to measure actual content + const frontHeight = cardFrontRef.current.scrollHeight; + const backHeight = cardBackRef.current.scrollHeight; + + console.log('Heights calculated:', { frontHeight, backHeight }); // Debug log + + // Use the maximum height with padding + const maxHeight = Math.max(frontHeight, backHeight); + const paddedHeight = maxHeight + 40; // Add padding for visual spacing + + // Ensure minimum height for visual consistency + return Math.max(paddedHeight, 450); + }; + + // Update card height only when card content changes (not on flip) + useLayoutEffect(() => { + if (mounted && cardFrontRef.current && cardBackRef.current) { + // Wait for DOM to be fully rendered + const timer = setTimeout(() => { + const newHeight = calculateCardHeight(); + setCardHeight(newHeight); + }, 50); + + return () => clearTimeout(timer); + } + }, [currentCardIndex, mounted]); + + // Client-side mounting + useEffect(() => { + setMounted(true) + loadDueCards() // 載入到期詞卡 + }, []) + + // 載入到期詞卡列表 + const loadDueCards = async () => { + try { + setIsLoadingCard(true) + console.log('🔍 開始載入到期詞卡...'); + + // 完全使用後端API數據 + const apiResult = await flashcardsService.getDueFlashcards(50); + console.log('📡 API回應結果:', apiResult); + + if (apiResult.success && apiResult.data && apiResult.data.length > 0) { + const cardsToUse = apiResult.data; + console.log('✅ 載入後端API數據成功:', cardsToUse.length, '張詞卡'); + console.log('📋 詞卡列表:', cardsToUse.map(c => c.word)); + + // 查詢已完成的測驗 + const cardIds = cardsToUse.map(c => c.id); + let completedTests: any[] = []; + + try { + const completedTestsResult = await flashcardsService.getCompletedTests(cardIds); + if (completedTestsResult.success && completedTestsResult.data) { + completedTests = completedTestsResult.data; + console.log('📊 已完成測驗:', completedTests.length, '個'); + } else { + console.log('⚠️ 查詢已完成測驗失敗,使用空列表:', completedTestsResult.error); + } + } catch (error) { + console.error('💥 查詢已完成測驗異常:', error); + console.log('📝 繼續使用空的已完成測驗列表'); + } + + // 計算每張詞卡剩餘的測驗 + const userCEFR = localStorage.getItem('userEnglishLevel') || 'A2'; + let remainingTestItems: TestItem[] = []; + let order = 1; + + cardsToUse.forEach(card => { + const wordCEFR = card.difficultyLevel || 'A2'; + const allTestTypes = getReviewTypesByCEFR(userCEFR, wordCEFR); + + // 找出該詞卡已完成的測驗類型 + 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, + testName: getModeLabel(testType), + isCompleted: false, + isCurrent: false, + order + }); + order++; + }); + }); + + if (remainingTestItems.length === 0) { + console.log('🎉 所有測驗都已完成!'); + setShowComplete(true); + return; + } + + console.log('📝 剩餘測驗項目:', remainingTestItems.length, '個'); + + // 設置狀態 + setTotalTests(remainingTestItems.length); + setTestItems(remainingTestItems); + setCurrentTestItemIndex(0); + setCompletedTests(0); + + // 找到第一個測驗項目對應的詞卡 + const firstTestItem = remainingTestItems[0]; + const firstCard = cardsToUse.find(c => c.id === firstTestItem.cardId); + + if (firstCard && firstTestItem) { + setCurrentCard(firstCard); + setCurrentCardIndex(cardsToUse.findIndex(c => c.id === firstCard.id)); + + // 設置測驗模式為第一個測驗的類型 + const modeMapping: { [key: string]: typeof mode } = { + 'flip-memory': 'flip-memory', + 'vocab-choice': 'vocab-choice', + 'vocab-listening': 'vocab-listening', + 'sentence-fill': 'sentence-fill', + 'sentence-reorder': 'sentence-reorder', + 'sentence-speaking': 'sentence-speaking', + 'sentence-listening': 'sentence-listening' + }; + + const selectedMode = modeMapping[firstTestItem.testType] || 'flip-memory'; + setMode(selectedMode); + setIsAutoSelecting(false); + + // 標記第一個測驗項目為當前狀態 + setTestItems(prev => + prev.map((item, index) => + index === 0 + ? { ...item, isCurrent: true } + : item + ) + ); + + console.log(`🎯 恢復到未完成測驗: ${firstCard.word} - ${firstTestItem.testType}`); + } + } else { + // 沒有到期詞卡 + console.log('❌ API回應:', { + success: apiResult.success, + dataExists: !!apiResult.data, + dataLength: apiResult.data?.length, + error: apiResult.error + }); + setDueCards([]); + setCurrentCard(null); + setShowNoDueCards(true); + } + + } catch (error) { + console.error('💥 載入到期詞卡失敗:', error); + setDueCards([]); + setCurrentCard(null); + setShowNoDueCards(true); + } finally { + setIsLoadingCard(false); + } + } + + // 智能載入下一張卡片並自動選擇模式 + const loadNextCardWithAutoMode = async (cardIndex: number) => { + try { + setIsAutoSelecting(true); + + // 等待dueCards載入完成 + if (dueCards.length === 0) { + console.log('等待詞卡載入...'); + return; + } + + const card = dueCards[cardIndex]; + if (!card) { + setShowComplete(true); + setIsAutoSelecting(false); + return; + } + + setCurrentCard(card); + setCurrentCardIndex(cardIndex); + + // 系統自動選擇最適合的複習模式 + const selectedMode = await selectOptimalReviewMode(card); + setMode(selectedMode); + + // 重置所有答題狀態 + resetAllStates(); + + console.log(`載入卡片: ${card.word}, 選擇模式: ${selectedMode}`); + + } catch (error) { + console.error('載入卡片失敗:', error); + } finally { + setIsAutoSelecting(false); + } + } + + // 系統自動選擇最適合的複習模式 + const selectOptimalReviewMode = async (card: ExtendedFlashcard): Promise => { + try { + // 使用CEFR字符串進行智能選擇 + const userCEFRLevel = localStorage.getItem('userEnglishLevel') || 'A2'; + const wordCEFRLevel = card.difficultyLevel || 'A2'; + + console.log(`CEFR智能選擇: 用戶${userCEFRLevel} vs 詞彙${wordCEFRLevel}`); + + const apiResult = await flashcardsService.getOptimalReviewMode(card.id, userCEFRLevel, wordCEFRLevel); + + if (apiResult.success && apiResult.data?.selectedMode) { + const selectedMode = apiResult.data.selectedMode; + console.log(`後端智能選擇: ${selectedMode}`); + + // 映射到前端模式名稱 + const modeMapping: { [key: string]: typeof mode } = { + 'flip-memory': 'flip-memory', + 'vocab-choice': 'vocab-choice', + 'vocab-listening': 'vocab-listening', + 'sentence-fill': 'sentence-fill', + 'sentence-reorder': 'sentence-reorder', + 'sentence-speaking': 'sentence-speaking', + 'sentence-listening': 'sentence-listening' + }; + + return modeMapping[selectedMode] || 'flip-memory'; + } else { + console.log('後端API失敗,使用前端邏輯'); + } + } catch (error) { + console.error('智能選擇API錯誤:', error); + } + + // 備用: 使用前端CEFR邏輯 + const userCEFRLevel = localStorage.getItem('userEnglishLevel') || 'A2'; + const wordCEFRLevel = card.difficultyLevel || 'A2'; + const availableModes = getReviewTypesByCEFR(userCEFRLevel, wordCEFRLevel); + + const modeMapping: { [key: string]: typeof mode } = { + 'flip-memory': 'flip-memory', + 'vocab-choice': 'vocab-choice', + 'vocab-listening': 'vocab-listening', + 'sentence-fill': 'sentence-fill', + 'sentence-reorder': 'sentence-reorder', + 'sentence-speaking': 'sentence-speaking', + 'sentence-listening': 'sentence-listening' + }; + + const selectedType = availableModes[0] || 'flip-memory'; + console.log(`前端CEFR邏輯選擇: ${selectedType}`); + return modeMapping[selectedType] || 'flip-memory'; + } + + // 前端CEFR備用選擇邏輯 + const getReviewTypesByCEFR = (userCEFR: string, wordCEFR: string): string[] => { + const userLevel = getCEFRToLevel(userCEFR); + const wordLevel = getCEFRToLevel(wordCEFR); + const difficulty = wordLevel - userLevel; + + if (userCEFR === 'A1') { + return ['flip-memory', 'vocab-choice', 'vocab-listening']; + } else if (difficulty < -10) { + return ['sentence-reorder', 'sentence-fill']; + } else if (difficulty >= -10 && difficulty <= 10) { + return ['sentence-fill', 'sentence-reorder', 'sentence-speaking']; + } else { + return ['flip-memory', 'vocab-choice']; + } + } + + // CEFR轉換為數值 (前端計算用) + const getCEFRToLevel = (cefr: string): number => { + const mapping: { [key: string]: number } = { + 'A1': 20, 'A2': 35, 'B1': 50, 'B2': 65, 'C1': 80, 'C2': 95 + }; + return mapping[cefr] || 50; + } + + // 計算每張詞卡的測驗數量 + const calculateTestsForCard = (userCEFR: string, wordCEFR: string): number => { + const userLevel = getCEFRToLevel(userCEFR); + const wordLevel = getCEFRToLevel(wordCEFR); + const difficulty = wordLevel - userLevel; + + if (userCEFR === 'A1') { + return 3; // A1學習者:翻卡、選擇、聽力 + } else if (difficulty < -10) { + return 2; // 簡單詞彙:填空、重組 + } else if (difficulty >= -10 && difficulty <= 10) { + return 3; // 適中詞彙:填空、重組、口說 + } else { + return 2; // 困難詞彙:翻卡、選擇 + } + } + + // 取得當前學習情境 + const getCurrentContext = (userCEFR: string, wordCEFR: string): string => { + const userLevel = getCEFRToLevel(userCEFR); + const wordLevel = getCEFRToLevel(wordCEFR); + const difficulty = wordLevel - userLevel; + + if (userCEFR === 'A1') return 'A1學習者'; + if (difficulty < -10) return '簡單詞彙'; + if (difficulty >= -10 && difficulty <= 10) return '適中詞彙'; + return '困難詞彙'; + } + + // 生成完整四情境對照表數據 + const generateContextTable = (currentUserCEFR: string, currentWordCEFR: string) => { + const currentContext = getCurrentContext(currentUserCEFR, currentWordCEFR); + + const contexts = [ + { + type: 'A1學習者', + icon: '🛡️', + reviewTypes: ['🔄翻卡記憶', '✅詞彙選擇', '🎧詞彙聽力'], + purpose: '建立基礎信心', + condition: '用戶等級 = A1', + description: '初學者保護機制,使用最基礎的3種題型' + }, + { + type: '簡單詞彙', + icon: '🎯', + reviewTypes: ['✏️例句填空', '🔀例句重組'], + purpose: '應用練習', + condition: '用戶等級 > 詞彙等級', + description: '詞彙對您較簡單,重點練習拼寫和語法應用' + }, + { + type: '適中詞彙', + icon: '⚖️', + reviewTypes: ['✏️例句填空', '🔀例句重組', '🗣️例句口說'], + purpose: '全方位練習', + condition: '用戶等級 ≈ 詞彙等級', + description: '詞彙難度適中,進行聽說讀寫全方位練習' + }, + { + type: '困難詞彙', + icon: '📚', + reviewTypes: ['🔄翻卡記憶', '✅詞彙選擇'], + purpose: '基礎重建', + condition: '用戶等級 < 詞彙等級', + description: '詞彙對您較困難,回歸基礎重建記憶' + } + ]; + + return contexts.map(context => ({ + ...context, + isCurrent: context.type === currentContext + })); + } + + // 取得題型圖標 + const getModeIcon = (mode: string): string => { + const icons: { [key: string]: string } = { + 'flip-memory': '🔄', + 'vocab-choice': '✅', + 'vocab-listening': '🎧', + 'sentence-listening': '👂', + 'sentence-fill': '✏️', + 'sentence-reorder': '🔀', + 'sentence-speaking': '🗣️' + }; + return icons[mode] || '📝'; + } + + // 取得題型中文名稱 + const getModeLabel = (mode: string): string => { + const labels: { [key: string]: string } = { + 'flip-memory': '翻卡記憶', + 'vocab-choice': '詞彙選擇', + 'vocab-listening': '詞彙聽力', + 'sentence-listening': '例句聽力', + 'sentence-fill': '例句填空', + 'sentence-reorder': '例句重組', + 'sentence-speaking': '例句口說' + }; + return labels[mode] || mode; + } + + // 生成測驗項目列表 + const generateTestItems = (cards: ExtendedFlashcard[], userCEFR: string): TestItem[] => { + const items: TestItem[] = []; + let order = 1; + + cards.forEach(card => { + const wordCEFR = card.difficultyLevel || 'A2'; + const testTypes = getReviewTypesByCEFR(userCEFR, wordCEFR); + + testTypes.forEach(testType => { + items.push({ + id: `${card.id}-${testType}`, + cardId: card.id, + word: card.word, + testType, + testName: getModeLabel(testType), + isCompleted: false, + isCurrent: false, + order + }); + order++; + }); + }); + + return items; + } + + // 按詞卡分組測驗項目 + const groupTestItemsByCard = (items: TestItem[]): CardTestGroup[] => { + const grouped = items.reduce((acc, item) => { + const cardId = item.cardId; + if (!acc[cardId]) { + const card = dueCards.find(c => c.id === cardId); + const userCEFR = localStorage.getItem('userEnglishLevel') || 'A2'; + const wordCEFR = card?.difficultyLevel || 'A2'; + + acc[cardId] = { + cardId, + word: item.word, + context: getCurrentContext(userCEFR, wordCEFR), + tests: [] + }; + } + acc[cardId].tests.push(item); + return acc; + }, {} as Record); + + return Object.values(grouped); + } + + // 初始化詞卡複習會話 + const initializeCardReviewSession = (card: ExtendedFlashcard): CardReviewSession => { + const userCEFR = localStorage.getItem('userEnglishLevel') || 'A2'; + const wordCEFR = card.difficultyLevel || 'A2'; + const plannedTests = getReviewTypesByCEFR(userCEFR, wordCEFR); + + return { + cardId: card.id, + word: card.word, + plannedTests, + completedTests: [], + startedAt: new Date(), + isCompleted: false + }; + } + + // 開始詞卡複習會話 + const startCardReviewSession = (card: ExtendedFlashcard) => { + const session = initializeCardReviewSession(card); + setCurrentCardSession(session); + + // 更新會話映射 + setCardReviewSessions(prev => new Map(prev.set(card.id, session))); + + console.log(`🎯 開始詞卡複習會話: ${card.word}`, { + plannedTests: session.plannedTests, + totalTests: session.plannedTests.length + }); + } + + // 檢查詞卡是否已完成所有測驗 + const isCardReviewCompleted = (cardId: string): boolean => { + const session = cardReviewSessions.get(cardId); + return session?.isCompleted || false; + } + + // 獲取詞卡的下一個測驗類型 + const getNextTestTypeForCard = (cardId: string): string | null => { + const session = cardReviewSessions.get(cardId); + if (!session) return null; + + const completedTestTypes = session.completedTests.map(t => t.testType); + const nextTestType = session.plannedTests.find(testType => + !completedTestTypes.includes(testType) + ); + + return nextTestType || null; + } + + // 重置所有答題狀態 + const resetAllStates = () => { + setIsFlipped(false); + setSelectedAnswer(null); + setShowResult(false); + setFillAnswer(''); + setShowHint(false); + setShuffledWords([]); + setArrangedWords([]); + setReorderResult(null); + setQuizOptions([]); + } + + // Quiz options generation + useEffect(() => { + if (!currentCard) return; + + const currentWord = currentCard.word; + + // Generate quiz options with current word and other words + const otherWords = dueCards + .filter(card => card.id !== currentCard.id) + .map(card => card.word); + + // 優先從其他詞卡生成選項,必要時使用備用詞彙 + const selectedOtherWords: string[] = []; + + // 從其他詞卡取得選項 + for (const word of otherWords) { + if (selectedOtherWords.length >= 3) break; + if (word !== currentWord && !selectedOtherWords.includes(word)) { + selectedOtherWords.push(word); + } + } + + // 如果詞卡不足,補充基礎詞彙 + if (selectedOtherWords.length < 3) { + const backupWords = ['important', 'beautiful', 'interesting', 'difficult', 'wonderful', 'excellent']; + for (const word of backupWords) { + if (selectedOtherWords.length >= 3) break; + if (word !== currentWord && !selectedOtherWords.includes(word)) { + selectedOtherWords.push(word); + } + } + } + + // 確保有4個選項:當前詞彙 + 3個其他選項 + const options = [currentWord, ...selectedOtherWords.slice(0, 3)].sort(() => Math.random() - 0.5); + setQuizOptions(options); + + // Reset quiz state when card changes + setSelectedAnswer(null); + setShowResult(false); + }, [currentCard, dueCards]) + + // Sentence options generation for sentence listening + useEffect(() => { + if (!currentCard || mode !== 'sentence-listening') return; + + const currentSentence = currentCard.example; + + // Generate sentence options with current sentence and other sentences + const otherSentences = dueCards + .filter(card => card.id !== currentCard.id) + .map(card => card.example); + + // 優先從其他詞卡的例句生成選項 + const selectedOtherSentences: string[] = []; + + // 從其他詞卡的例句取得選項 + for (const sentence of otherSentences) { + if (selectedOtherSentences.length >= 3) break; + if (sentence !== currentSentence && !selectedOtherSentences.includes(sentence)) { + selectedOtherSentences.push(sentence); + } + } + + // 如果例句不足,使用基礎例句補充 + if (selectedOtherSentences.length < 3) { + const backupSentences = [ + 'This is a very important decision.', + 'The weather looks beautiful today.', + 'We need to find a good solution.', + 'Learning English can be interesting.' + ]; + + for (const sentence of backupSentences) { + if (selectedOtherSentences.length >= 3) break; + if (sentence !== currentSentence && !selectedOtherSentences.includes(sentence)) { + selectedOtherSentences.push(sentence); + } + } + } + + // 確保有4個選項:當前例句 + 3個其他選項 + const options = [currentSentence, ...selectedOtherSentences.slice(0, 3)].sort(() => Math.random() - 0.5); + setSentenceOptions(options); + + }, [currentCard, dueCards, mode]) + + // Initialize sentence reorder when card changes or mode switches to sentence-reorder + useEffect(() => { + if (mode === 'sentence-reorder' && currentCard) { + const words = currentCard.example.split(/\s+/).filter(word => word.length > 0) + const shuffled = [...words].sort(() => Math.random() - 0.5) + setShuffledWords(shuffled) + setArrangedWords([]) + // 只在卡片或模式切換時重置結果,不在其他狀態變化時重置 + if (reorderResult !== null) { + setReorderResult(null) + } + } + }, [currentCard, mode]) // 移除reorderResult依賴,避免循環重置 + + // Sentence reorder handlers + const handleWordClick = (word: string) => { + // Move word from shuffled to arranged + setShuffledWords(prev => prev.filter(w => w !== word)) + setArrangedWords(prev => [...prev, word]) + setReorderResult(null) + } + + const handleRemoveFromArranged = (word: string) => { + setArrangedWords(prev => prev.filter(w => w !== word)) + setShuffledWords(prev => [...prev, word]) + setReorderResult(null) + } + + const handleCheckReorderAnswer = async () => { + if (!currentCard) return; + + const userSentence = arrangedWords.join(' ') + const correctSentence = currentCard.example + const isCorrect = userSentence.toLowerCase().trim() === correctSentence.toLowerCase().trim() + setReorderResult(isCorrect) + + // 更新分數 + setScore(prev => ({ + correct: isCorrect ? prev.correct + 1 : prev.correct, + total: prev.total + 1 + })) + + // 記錄測驗結果 + await recordTestResult(isCorrect, userSentence); + } + + const handleResetReorder = () => { + if (!currentCard) return; + + const words = currentCard.example.split(/\s+/).filter(word => word.length > 0) + const shuffled = [...words].sort(() => Math.random() - 0.5) + setShuffledWords(shuffled) + setArrangedWords([]) + setReorderResult(null) + } + + const handleFlip = () => { + setIsFlipped(!isFlipped) + } + + // 移動到下一個測驗或下一張詞卡 + const handleNext = async () => { + if (!currentCard || !currentCardSession) return; + + // 檢查當前詞卡是否還有未完成的測驗 + const nextTestType = getNextTestTypeForCard(currentCard.id); + + if (nextTestType) { + // 當前詞卡還有測驗未完成,切換到下一個測驗類型 + const modeMapping: { [key: string]: typeof mode } = { + 'flip-memory': 'flip-memory', + 'vocab-choice': 'vocab-choice', + 'vocab-listening': 'vocab-listening', + 'sentence-fill': 'sentence-fill', + 'sentence-reorder': 'sentence-reorder', + 'sentence-speaking': 'sentence-speaking', + 'sentence-listening': 'sentence-listening' + }; + + const nextMode = modeMapping[nextTestType] || 'flip-memory'; + setMode(nextMode); + resetAllStates(); + + // 更新測驗項目的當前狀態 + setTestItems(prev => + prev.map(item => + item.cardId === currentCard.id && item.testType === nextTestType + ? { ...item, isCurrent: true } + : { ...item, isCurrent: false } + ) + ); + + console.log(`🔄 切換到下一個測驗: ${nextTestType} for ${currentCard.word}`); + } else { + // 當前詞卡的所有測驗都已完成,移動到下一張詞卡 + if (currentCardIndex < dueCards.length - 1) { + const nextCardIndex = currentCardIndex + 1; + const nextCard = dueCards[nextCardIndex]; + + // 開始新詞卡的複習會話 + startCardReviewSession(nextCard); + + await loadNextCardWithAutoMode(nextCardIndex); + console.log(`➡️ 移動到下一張詞卡: ${nextCard.word}`); + } else { + // 所有詞卡都已完成 + setShowComplete(true); + console.log(`🎉 所有詞卡復習完成!`); + } + } + } + + const handlePrevious = async () => { + // 暫時保持簡單的向前導航 + if (currentCardIndex > 0) { + await loadNextCardWithAutoMode(currentCardIndex - 1); + } + } + + const handleQuizAnswer = async (answer: string) => { + if (showResult || !currentCard) return + + setSelectedAnswer(answer) + setShowResult(true) + + const isCorrect = answer === currentCard.word + setScore(prev => ({ + correct: isCorrect ? prev.correct + 1 : prev.correct, + total: prev.total + 1 + })) + + // 記錄測驗結果到資料庫 + await recordTestResult(isCorrect, answer); + } + + // 記錄測驗結果到資料庫(立即保存) + const recordTestResult = async (isCorrect: boolean, userAnswer?: string, confidenceLevel?: number) => { + if (!currentCard) return; + + // 檢查認證狀態 + const token = localStorage.getItem('auth_token'); + if (!token) { + console.error('❌ 未找到認證token,請重新登入'); + return; + } + + try { + console.log('🔄 開始記錄測驗結果到資料庫...', { + flashcardId: currentCard.id, + testType: mode, + word: currentCard.word, + isCorrect, + hasToken: !!token + }); + + // 立即記錄到資料庫 + const result = await flashcardsService.recordTestCompletion({ + flashcardId: currentCard.id, + testType: mode, + isCorrect, + userAnswer, + confidenceLevel, + responseTimeMs: 2000 + }); + + if (result.success) { + console.log('✅ 測驗結果已記錄到資料庫:', mode, 'for', currentCard.word); + + // 更新本地UI狀態 + setCompletedTests(prev => prev + 1); + + // 標記當前測驗項目為完成 + setTestItems(prev => + prev.map((item, index) => + index === currentTestItemIndex + ? { ...item, isCompleted: true, isCurrent: false } + : item + ) + ); + + // 移到下一個測驗項目 + setCurrentTestItemIndex(prev => prev + 1); + + // 檢查是否還有剩餘測驗 + setTimeout(() => { + loadNextUncompletedTest(); + }, 1500); + + } else { + console.error('❌ 記錄測驗結果失敗:', result.error); + if (result.error?.includes('Test already completed') || result.error?.includes('already completed')) { + console.log('⚠️ 測驗已完成,跳到下一個'); + loadNextUncompletedTest(); + } else { + // 其他錯誤,先更新UI狀態避免卡住 + setCompletedTests(prev => prev + 1); + setCurrentTestItemIndex(prev => prev + 1); + setTimeout(() => { + loadNextUncompletedTest(); + }, 1500); + } + } + } catch (error) { + console.error('💥 記錄測驗結果異常:', error); + // 即使出錯也更新進度,避免卡住 + setCompletedTests(prev => prev + 1); + setCurrentTestItemIndex(prev => prev + 1); + setTimeout(() => { + loadNextUncompletedTest(); + }, 1500); + } + } + + // 載入下一個未完成的測驗 + const loadNextUncompletedTest = () => { + if (currentTestItemIndex + 1 < testItems.length) { + // 還有測驗項目 + const nextTestItem = testItems[currentTestItemIndex + 1]; + const nextCard = dueCards.find(c => c.id === nextTestItem.cardId); + + if (nextCard) { + setCurrentCard(nextCard); + setCurrentCardIndex(dueCards.findIndex(c => c.id === nextCard.id)); + + // 設置下一個測驗類型 + const modeMapping: { [key: string]: typeof mode } = { + 'flip-memory': 'flip-memory', + 'vocab-choice': 'vocab-choice', + 'vocab-listening': 'vocab-listening', + 'sentence-fill': 'sentence-fill', + 'sentence-reorder': 'sentence-reorder', + 'sentence-speaking': 'sentence-speaking', + 'sentence-listening': 'sentence-listening' + }; + + const nextMode = modeMapping[nextTestItem.testType] || 'flip-memory'; + setMode(nextMode); + resetAllStates(); + + // 更新測驗項目的當前狀態 + setTestItems(prev => + prev.map((item, index) => + index === currentTestItemIndex + 1 + ? { ...item, isCurrent: true } + : { ...item, isCurrent: false } + ) + ); + + console.log(`🔄 載入下一個測驗: ${nextCard.word} - ${nextTestItem.testType}`); + } + } else { + // 所有測驗完成 + console.log('🎉 所有測驗完成!'); + setShowComplete(true); + } + } + + // 完成詞卡複習並提交到後端 + const completeCardReview = async (session: CardReviewSession) => { + try { + // 計算綜合表現指標 + const correctCount = session.completedTests.filter(t => t.isCorrect).length; + const totalTests = session.completedTests.length; + const accuracy = totalTests > 0 ? correctCount / totalTests : 0; + + // 計算平均信心等級(用於翻卡記憶測驗) + const confidenceTests = session.completedTests.filter(t => t.confidenceLevel !== undefined); + const avgConfidence = confidenceTests.length > 0 + ? confidenceTests.reduce((sum, t) => sum + (t.confidenceLevel || 3), 0) / confidenceTests.length + : 3; + + // 計算平均答題時間 + const avgResponseTime = session.completedTests.reduce((sum, t) => sum + t.responseTimeMs, 0) / totalTests; + + // 確定主要測驗類型(用於後端SM2算法) + const primaryTestType = session.completedTests[0]?.testType || 'flip-memory'; + + console.log(`🔥 提交詞卡完整復習結果:`, { + word: session.word, + accuracy: `${Math.round(accuracy * 100)}%`, + avgConfidence, + avgResponseTime: `${avgResponseTime}ms`, + primaryTestType, + completedTests: session.completedTests.length + }); + + // 提交到後端 + const result = await flashcardsService.submitReview(session.cardId, { + isCorrect: accuracy >= 0.7, // 70%以上正確率視為通過 + confidenceLevel: Math.round(avgConfidence), + questionType: primaryTestType, + userAnswer: `綜合${totalTests}個測驗,正確率${Math.round(accuracy * 100)}%`, + timeTaken: Math.round(avgResponseTime) + }); + + if (result.success && result.data) { + console.log('✅ 詞卡復習結果提交成功:', result.data); + + // 更新詞卡的熟悉度等資訊 + if (currentCard && currentCard.id === session.cardId) { + setCurrentCard(prev => prev ? { + ...prev, + masteryLevel: result.data!.masteryLevel, + nextReviewDate: result.data!.nextReviewDate + } : null); + } + + // 增加已完成詞卡數量 + setCompletedCards(prev => prev + 1); + + console.log(`🎉 詞卡 ${session.word} 復習完成!新熟悉度: ${result.data.masteryLevel}%, 下次復習: ${result.data.nextReviewDate}`); + } else { + console.error('詞卡復習結果提交失敗:', result.error); + } + + } catch (error) { + console.error('完成詞卡復習時發生錯誤:', error); + } + } + + const handleFillAnswer = async () => { + if (showResult || !currentCard) return + + setShowResult(true) + + const isCorrect = fillAnswer.toLowerCase().trim() === currentCard.word.toLowerCase() + setScore(prev => ({ + correct: isCorrect ? prev.correct + 1 : prev.correct, + total: prev.total + 1 + })) + + // 記錄測驗結果 + await recordTestResult(isCorrect, fillAnswer); + } + + const handleListeningAnswer = async (answer: string) => { + if (showResult || !currentCard) return + + setSelectedAnswer(answer) + setShowResult(true) + + const isCorrect = answer === currentCard.word + setScore(prev => ({ + correct: isCorrect ? prev.correct + 1 : prev.correct, + total: prev.total + 1 + })) + + // 記錄測驗結果 + await recordTestResult(isCorrect, answer); + } + + const handleSpeakingAnswer = async (transcript: string) => { + if (!currentCard) return + + setShowResult(true) + + const isCorrect = transcript.toLowerCase().includes(currentCard.word.toLowerCase()) + setScore(prev => ({ + correct: isCorrect ? prev.correct + 1 : prev.correct, + total: prev.total + 1 + })) + + // 記錄測驗結果 + await recordTestResult(isCorrect, transcript); + } + + const handleSentenceListeningAnswer = async (answer: string) => { + if (showResult || !currentCard) return + + setSelectedAnswer(answer) + setShowResult(true) + + const isCorrect = answer === currentCard.example + setScore(prev => ({ + correct: isCorrect ? prev.correct + 1 : prev.correct, + total: prev.total + 1 + })) + + // 記錄測驗結果 + await recordTestResult(isCorrect, answer); + } + + const handleReportSubmit = () => { + console.log('Report submitted:', { + card: reportingCard, + reason: reportReason + }) + setShowReportModal(false) + setReportReason('') + setReportingCard(null) + } + + const handleRestart = async () => { + setScore({ correct: 0, total: 0 }) + setCompletedTests(0) + setTotalTests(0) + setTestItems([]) + setCurrentTestItemIndex(0) + setShowComplete(false) + setShowNoDueCards(false) + await loadDueCards(); // 重新載入到期詞卡 + } + + // Show loading screen until mounted or while loading cards + if (!mounted || isLoadingCard) { + return ( +
+
+ {isAutoSelecting ? '系統正在選擇最適合的複習方式...' : '載入中...'} +
+
+ ) + } + + // Show no due cards screen + if (showNoDueCards) { + return ( +
+ +
+
+
📚
+

+ 今日學習已完成! +

+

+ 目前沒有到期需要複習的詞卡。您可以: +

+ +
+
+
💡 建議行動
+
    +
  • • 前往詞卡管理頁面新增詞卡
  • +
  • • 查看學習統計和進度
  • +
  • • 調整學習目標和設定
  • +
+
+
+ +
+ + +
+ + +
+
+
+ ) + } + + // Show current card interface + if (!currentCard) { + return ( +
+
載入詞卡中...
+
+ ) + } + + return ( +
+ {/* Navigation */} + + +
+ {/* Progress Bar */} +
+
+ 學習進度 +
+ {/* 詞卡進度 */} +
+ 詞卡: + {completedCards} + / + {dueCards.length} + {dueCards.length > 0 && ( + + ({Math.round((completedCards / dueCards.length) * 100)}%) + + )} +
+ + {/* 測驗進度 */} + +
+
+ + {/* 雙層進度條 */} +
+ {/* 詞卡進度條 */} +
+ 詞卡 +
+
0 ? (completedCards / dueCards.length) * 100 : 0}%` }} + >
+
+ + {dueCards.length > 0 ? Math.round((completedCards / dueCards.length) * 100) : 0}% + +
+ + {/* 測驗進度條 */} +
+ 測驗 +
setShowTaskListModal(true)} + title="點擊查看詳細任務清單" + > +
0 ? (completedTests / totalTests) * 100 : 0}%` }} + >
+
+ + {totalTests > 0 ? Math.round((completedTests / totalTests) * 100) : 0}% + +
+
+
+ + {mode === 'flip-memory' ? ( + /* Flip Card Mode */ +
+ {/* Error Report Button for Flip Mode */} +
+ +
+ +
+
+ {/* Front */} +
+
+ {/* Title and Instructions */} +
+

+ 翻卡記憶 +

+ + {currentCard.difficultyLevel} + +
+ + {/* Instructions Test Action */} +

+ 點擊卡片翻面,根據你對單字的熟悉程度進行自我評估: +

+ + {/* Word Display */} +
+
+

+ {currentCard.word} +

+
+ + {currentCard.pronunciation} + + +
+
+
+
+
+ + {/* Back */} +
+
+ {/* Content Sections */} +
+ {/* Definition */} +
+

定義

+

{currentCard.definition}

+
+ + {/* Example */} +
+

例句

+
+

"{currentCard.example}"

+
+ +
+
+

"{currentCard.exampleTranslation}"

+
+ + {/* Synonyms */} +
+

同義詞

+
+ {(currentCard.synonyms || []).map((synonym, index) => ( + + {synonym} + + ))} +
+
+
+
+
+
+
+ + {/* Navigation */} +
+ + +
+
+ ) : mode === 'vocab-choice' ? ( + /* Vocab Choice Mode - 詞彙選擇 */ +
+ {/* Error Report Button for Quiz Mode */} +
+ +
+ +
+ {/* Title in top-left */} +
+

+ 詞彙選擇 +

+ + {currentCard.difficultyLevel} + +
+ + {/* Instructions Test Action */} +

+ 請選擇符合上述定義的英文詞彙: +

+ +
+
+

定義

+

{currentCard.definition}

+
+ +
+ +
+ {quizOptions.map((option, idx) => ( + + ))} +
+ + {showResult && ( +
+

+ {selectedAnswer === currentCard.word ? '正確!' : '錯誤!'} +

+ + {selectedAnswer !== currentCard.word && ( +
+

+ 正確答案是:{currentCard.word} +

+
+ )} + +
+
+
+ 發音: + {currentCard.pronunciation} + +
+
+
+
+ )} +
+ + {/* Navigation */} +
+ + +
+
+ ) : mode === 'sentence-fill' ? ( + /* Fill in the Blank Mode - 填空題 */ +
+ {/* Error Report Button for Fill Mode */} +
+ +
+ +
+ {/* Title in top-left */} +
+

+ 例句填空 +

+ + {currentCard.difficultyLevel} + +
+ + {/* Example Image */} + {currentCard.exampleImage && ( +
+
+ Example illustration setModalImage(currentCard.exampleImage || null)} + /> +
+
+ )} + + {/* Instructions Test Action */} +

+ 請點擊例句中的空白處輸入正確的單字: +

+ + {/* Example Sentence with Blanks */} +
+
+
+ {currentCard.example.split(new RegExp(`(${currentCard.word})`, 'gi')).map((part, index) => { + const isTargetWord = part.toLowerCase() === currentCard.word.toLowerCase(); + return isTargetWord ? ( + + setFillAnswer(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !showResult && fillAnswer.trim()) { + handleFillAnswer() + } + }} + placeholder="" + disabled={showResult} + className={`inline-block px-2 py-1 text-center bg-transparent focus:outline-none disabled:bg-gray-100 ${ + fillAnswer + ? 'border-b-2 border-blue-500' + : 'border-b-2 border-dashed border-gray-400 focus:border-blue-400 focus:border-solid' + }`} + style={{ width: `${Math.max(100, Math.max(currentCard.word.length * 12, fillAnswer.length * 12 + 20))}px` }} + /> + {!fillAnswer && ( + + ____ + + )} + + ) : ( + {part} + ); + })} +
+
+
+ + {/* Action Buttons */} +
+ {!showResult && fillAnswer.trim() && ( + + )} + +
+ + {/* Hint Section */} + {showHint && ( +
+

詞彙定義:

+

{currentCard.definition}

+
+ )} + + {showResult && ( +
+

+ {fillAnswer.toLowerCase().trim() === currentCard.word.toLowerCase() ? '正確!' : '錯誤!'} +

+ + {fillAnswer.toLowerCase().trim() !== currentCard.word.toLowerCase() && ( +
+

+ 正確答案是:{currentCard.word} +

+
+ )} + +
+
+

+ {currentCard.pronunciation} + +

+
+ +
+
+
+
+ )} +
+ + {/* Navigation */} +
+ + +
+
+ ) : mode === 'vocab-listening' ? ( + /* Listening Test Mode - 聽力測試 */ +
+ {/* Error Report Button for Listening Mode */} +
+ +
+ +
+ {/* Title in top-left */} +
+

+ 詞彙聽力 (暫時不上線) +

+ + {currentCard.difficultyLevel} + +
+ + {/* Instructions Test Action */} +

+ 請聽發音並選擇正確的英文單字: +

+ + {/* Content Sections */} +
+ {/* Audio */} +
+

發音

+
+ {currentCard.pronunciation} + +
+
+
+ + {/* Word Options */} +
+ {[currentCard.word, 'determine', 'achieve', 'consider'].map((word) => ( + + ))} +
+ + {showResult && ( +
+

+ {selectedAnswer === currentCard.word ? '正確!' : '錯誤!'} +

+ {selectedAnswer !== currentCard.word && ( +
+

+ 正確答案是:{currentCard.word} +

+
+ 發音:{currentCard.pronunciation} + +
+
+ )} +
+ )} +
+ + {/* Navigation */} +
+ + +
+
+ ) : mode === 'sentence-speaking' ? ( + /* Speaking Test Mode - 口說測試 */ +
+ {/* Error Report Button for Speaking Mode */} +
+ +
+ +
+ {/* Title in top-left */} +
+

+ 例句口說 +

+ + {currentCard.difficultyLevel} + +
+ +
+ { + // 簡化處理:直接顯示結果 + handleSpeakingAnswer(currentCard.example) + }} + /> +
+ + {showResult && ( +
+

+ 錄音完成! +

+

+ 系統正在評估你的發音... +

+
+ )} +
+ + {/* Navigation */} +
+ + +
+
+ ) : mode === 'sentence-listening' ? ( + /* Sentence Listening Test Mode - 例句聽力題 */ +
+ {/* Error Report Button */} +
+ +
+ +
+ {/* Title in top-left */} +
+

+ 例句聽力 +

+ + {currentCard.difficultyLevel} + +
+ + {/* Instructions Test Action */} +

+ 請聽例句並選擇正確的選項: +

+ +
+ +
+ +

+ 點擊播放聽例句 +

+
+
+ +
+ {sentenceOptions.map((sentence, idx) => ( + + ))} +
+ + {showResult && ( +
+

+ {selectedAnswer === currentCard.example ? '正確!' : '錯誤!'} +

+ + {selectedAnswer !== currentCard.example && ( +
+

+ 正確答案是:"{currentCard.example}" +

+

+ 中文翻譯:{currentCard.exampleTranslation} +

+
+ )} +
+ )} +
+ + {/* Navigation */} +
+ + +
+
+ ) : mode === 'sentence-reorder' ? ( + /* Sentence Reorder Mode - 例句重組題 */ +
+ {/* Error Report Button */} +
+ +
+ +
+ {/* Title in top-left */} +
+

+ 例句重組 +

+ + {currentCard.difficultyLevel} + +
+ + + {/* Example Image */} + {currentCard.exampleImage && ( +
+
+ Example illustration setModalImage(currentCard.exampleImage || null)} + /> +
+
+ )} + + + + {/* Arranged Sentence Area */} +
+

重組區域:

+
+ {arrangedWords.length === 0 ? ( +
+ 答案區 +
+ ) : ( +
+ {arrangedWords.map((word, index) => ( +
handleRemoveFromArranged(word)} + > + {word} + × +
+ ))} +
+ )} +
+
+ + {/* Instructions Test Action */} +

+ 點擊下方單字,依序重組成正確的句子: +

+ + {/* Shuffled Words */} +
+

可用單字:

+
+ {shuffledWords.length === 0 ? ( +
+ 所有單字都已使用 +
+ ) : ( +
+ {shuffledWords.map((word, index) => ( + + ))} +
+ )} +
+
+ + {/* Control Buttons */} +
+ {arrangedWords.length > 0 && ( + + )} + +
+ + {/* Result Feedback */} + {reorderResult !== null && ( +
+

+ {reorderResult ? '正確!' : '錯誤!'} +

+ + {!reorderResult && ( +
+

+ 正確答案是:"{currentCard.example}" +

+
+ )} + +
+
+

+ 中文翻譯:{currentCard.exampleTranslation} +

+
+
+
+ )} +
+ + {/* Navigation */} +
+ + +
+
+ ) : null} + + {/* Report Modal */} + {showReportModal && ( +
+
+

回報錯誤

+
+

+ 單字:{reportingCard?.word} +

+
+
+ + +
+
+ + +
+
+
+ )} + + {/* Image Modal */} + {modalImage && ( +
setModalImage(null)} + > +
+ 放大圖片 + +
+
+ )} + + {/* Task List Modal */} + {showTaskListModal && ( +
+
+ {/* Header */} +
+

+ 📚 學習任務清單 +

+ +
+ + {/* Content */} +
+ {/* 進度統計 */} +
+
+ + 測驗進度: {completedTests} / {totalTests} ({totalTests > 0 ? Math.round((completedTests / totalTests) * 100) : 0}%) + +
+ ✅ 已完成: {testItems.filter(item => item.isCompleted).length} + ⏳ 進行中: {testItems.filter(item => item.isCurrent).length} + ⚪ 待完成: {testItems.filter(item => !item.isCompleted && !item.isCurrent).length} +
+
+
+
0 ? (completedTests / totalTests) * 100 : 0}%` }} + >
+
+
+ + {/* 任務清單 */} +
+ {groupTestItemsByCard(testItems).map((cardGroup, cardIndex) => ( +
+ {/* 詞卡標題 */} +
+ + 詞卡{cardIndex + 1}: {cardGroup.word} + + + {cardGroup.context} + + + {cardGroup.tests.length}個測驗 + +
+ + {/* 測驗項目 */} +
+ {cardGroup.tests.map(test => ( +
+ {/* 狀態圖標 */} + + {test.isCompleted ? '✅' : test.isCurrent ? '⏳' : '⚪'} + + + {/* 測驗資訊 */} +
+
+ {test.order}. {test.testName} +
+
+ {test.isCompleted ? '已完成' : + test.isCurrent ? '進行中' : '待完成'} +
+
+
+ ))} +
+
+ ))} +
+ + {testItems.length === 0 && ( +
+
📚
+

還沒有生成任務清單

+
+ )} +
+
+
+ )} + + {/* Complete Modal */} + {showComplete && ( + router.push('/dashboard')} + /> + )} + + {/* No Due Cards Modal */} + {showNoDueCards && ( +
+
+
📚
+

+ 今日學習已完成! +

+

+ 目前沒有到期需要複習的詞卡。您可以: +

+ +
+
+
💡 建議行動
+
    +
  • • 前往詞卡管理頁面新增詞卡
  • +
  • • 查看學習統計和進度
  • +
  • • 調整學習目標和設定
  • +
+
+
+ +
+ + +
+ + +
+
+ )} + +
+ + +
+ ) +} \ No newline at end of file diff --git a/frontend/app/learn/new-page.tsx b/frontend/app/learn-backup/page-v2-smaller.tsx similarity index 100% rename from frontend/app/learn/new-page.tsx rename to frontend/app/learn-backup/page-v2-smaller.tsx diff --git a/frontend/app/learn/page.tsx b/frontend/app/learn/page.tsx index daa1939..2816ab8 100644 --- a/frontend/app/learn/page.tsx +++ b/frontend/app/learn/page.tsx @@ -1,2157 +1,213 @@ 'use client' -import { useState, useEffect, useRef, useLayoutEffect } from 'react' +import { useState, useEffect } from 'react' import { useRouter } from 'next/navigation' import { Navigation } from '@/components/Navigation' -import AudioPlayer from '@/components/AudioPlayer' -import VoiceRecorder from '@/components/VoiceRecorder' import LearningComplete from '@/components/LearningComplete' -import ReviewTypeIndicator from '@/components/review/ReviewTypeIndicator' -import MasteryIndicator from '@/components/review/MasteryIndicator' -import { flashcardsService, type Flashcard } from '@/lib/services/flashcards' -import { calculateCurrentMastery, getReviewTypesByDifficulty } from '@/lib/utils/masteryCalculator' -// 測驗項目接口 -interface TestItem { - id: string; // 唯一ID: cardId + testType - cardId: string; // 所屬詞卡ID - word: string; // 詞卡單字 - testType: string; // 測驗類型 (flip-memory, vocab-choice, etc.) - testName: string; // 測驗中文名稱 - isCompleted: boolean; // 是否已完成 - isCurrent: boolean; // 是否為當前測驗 - order: number; // 執行順序 (1-8) -} +// 標準架構:全域組件和 hooks +import { ProgressTracker } from '@/components/learn/ProgressTracker' +import { TaskListModal } from '@/components/learn/TaskListModal' +import { ReviewContainer } from '@/components/learn/ReviewContainer' +import { LoadingStates } from '@/components/learn/LoadingStates' -// 詞卡測驗分組接口 -interface CardTestGroup { - cardId: string; - word: string; - context: string; - tests: TestItem[]; -} - -// 擴展的Flashcard接口,包含智能複習需要的欄位 -interface ExtendedFlashcard extends Omit { - nextReviewDate?: string; // 下次復習日期 (可選) - currentInterval?: number; // 當前間隔天數 - isOverdue?: boolean; // 是否逾期 - overdueDays?: number; // 逾期天數 - baseMasteryLevel?: number; // 基礎熟悉度 - lastReviewDate?: string; // 最後復習日期 - synonyms?: string[]; // 同義詞 - exampleImage?: string; // 例句圖片 - // 注意:userLevel和wordLevel已移除,改用即時CEFR轉換 -} - -// 單個測驗結果接口 -interface TestResult { - testType: string; // 測驗類型 - isCorrect: boolean; // 是否正確 - userAnswer?: string; // 用戶答案 - confidenceLevel?: number; // 信心等級 (1-5, 用於flip-memory) - responseTimeMs: number; // 答題時間 - completedAt: Date; // 完成時間 -} - -// 詞卡複習會話接口 -interface CardReviewSession { - cardId: string; // 詞卡ID - word: string; // 詞卡單字 - plannedTests: string[]; // 預定的測驗類型列表 - completedTests: TestResult[]; // 已完成的測驗結果 - startedAt: Date; // 開始時間 - isCompleted: boolean; // 是否完成所有測驗 -} +import { useReviewSession } from '@/hooks/learn/useReviewSession' +import { useTestQueue } from '@/hooks/learn/useTestQueue' +import { useProgressTracker } from '@/hooks/learn/useProgressTracker' +import { useTestAnswering } from '@/hooks/learn/useTestAnswering' export default function LearnPage() { const router = useRouter() const [mounted, setMounted] = useState(false) - // 智能複習狀態 - const [currentCard, setCurrentCard] = useState(null) - const [dueCards, setDueCards] = useState([]) - const [currentCardIndex, setCurrentCardIndex] = useState(0) - const [isLoadingCard, setIsLoadingCard] = useState(false) - - // 複習模式狀態 (系統自動選擇) - const [mode, setMode] = useState<'flip-memory' | 'vocab-choice' | 'vocab-listening' | 'sentence-listening' | 'sentence-fill' | 'sentence-reorder' | 'sentence-speaking'>('flip-memory') - const [isAutoSelecting, setIsAutoSelecting] = useState(true) - - // 答題狀態 - const [score, setScore] = useState({ correct: 0, total: 0 }) - const [selectedAnswer, setSelectedAnswer] = useState(null) - const [showResult, setShowResult] = useState(false) - const [fillAnswer, setFillAnswer] = useState('') - const [showHint, setShowHint] = useState(false) - const [isFlipped, setIsFlipped] = useState(false) - - // 測驗進度狀態 - const [totalTests, setTotalTests] = useState(0) // 所有測驗總數 - const [completedTests, setCompletedTests] = useState(0) // 已完成測驗數 - const [testItems, setTestItems] = useState([]) // 測驗項目列表 - const [currentTestItemIndex, setCurrentTestItemIndex] = useState(0) // 當前測驗項目索引 - - // 詞卡複習會話狀態 - const [cardReviewSessions, setCardReviewSessions] = useState>(new Map()) - const [currentCardSession, setCurrentCardSession] = useState(null) - const [completedCards, setCompletedCards] = useState(0) // 已完成復習的詞卡數 - - // UI狀態 + // UI 狀態 const [modalImage, setModalImage] = useState(null) const [showReportModal, setShowReportModal] = useState(false) const [reportReason, setReportReason] = useState('') const [reportingCard, setReportingCard] = useState(null) - const [showComplete, setShowComplete] = useState(false) - const [showNoDueCards, setShowNoDueCards] = useState(false) - const [showTaskListModal, setShowTaskListModal] = useState(false) - const [cardHeight, setCardHeight] = useState(400) - // 題型特定狀態 - const [quizOptions, setQuizOptions] = useState([]) - const [sentenceOptions, setSentenceOptions] = useState([]) + // 使用全域 hooks + const reviewSession = useReviewSession() + const testQueue = useTestQueue() + const progressTracker = useProgressTracker() + const testAnswering = useTestAnswering() - // 例句重組狀態 - const [shuffledWords, setShuffledWords] = useState([]) - const [arrangedWords, setArrangedWords] = useState([]) - const [reorderResult, setReorderResult] = useState(null) - - // Refs for measuring card content heights - const cardFrontRef = useRef(null) - const cardBackRef = useRef(null) - const cardContainerRef = useRef(null) - - // Calculate optimal card height based on content (only when card changes) - const calculateCardHeight = () => { - if (!cardFrontRef.current || !cardBackRef.current) return 400; - - // Get the scroll heights to measure actual content - const frontHeight = cardFrontRef.current.scrollHeight; - const backHeight = cardBackRef.current.scrollHeight; - - console.log('Heights calculated:', { frontHeight, backHeight }); // Debug log - - // Use the maximum height with padding - const maxHeight = Math.max(frontHeight, backHeight); - const paddedHeight = maxHeight + 40; // Add padding for visual spacing - - // Ensure minimum height for visual consistency - return Math.max(paddedHeight, 450); - }; - - // Update card height only when card content changes (not on flip) - useLayoutEffect(() => { - if (mounted && cardFrontRef.current && cardBackRef.current) { - // Wait for DOM to be fully rendered - const timer = setTimeout(() => { - const newHeight = calculateCardHeight(); - setCardHeight(newHeight); - }, 50); - - return () => clearTimeout(timer); - } - }, [currentCardIndex, mounted]); - - // Client-side mounting + // 初始化 useEffect(() => { setMounted(true) - loadDueCards() // 載入到期詞卡 + initializeLearnSession() }, []) - // 載入到期詞卡列表 - const loadDueCards = async () => { - try { - setIsLoadingCard(true) - console.log('🔍 開始載入到期詞卡...'); - - // 完全使用後端API數據 - const apiResult = await flashcardsService.getDueFlashcards(50); - console.log('📡 API回應結果:', apiResult); - - if (apiResult.success && apiResult.data && apiResult.data.length > 0) { - const cardsToUse = apiResult.data; - console.log('✅ 載入後端API數據成功:', cardsToUse.length, '張詞卡'); - console.log('📋 詞卡列表:', cardsToUse.map(c => c.word)); - - // 查詢已完成的測驗 - const cardIds = cardsToUse.map(c => c.id); - let completedTests: any[] = []; - - try { - const completedTestsResult = await flashcardsService.getCompletedTests(cardIds); - if (completedTestsResult.success && completedTestsResult.data) { - completedTests = completedTestsResult.data; - console.log('📊 已完成測驗:', completedTests.length, '個'); - } else { - console.log('⚠️ 查詢已完成測驗失敗,使用空列表:', completedTestsResult.error); - } - } catch (error) { - console.error('💥 查詢已完成測驗異常:', error); - console.log('📝 繼續使用空的已完成測驗列表'); - } - - // 計算每張詞卡剩餘的測驗 - const userCEFR = localStorage.getItem('userEnglishLevel') || 'A2'; - let remainingTestItems: TestItem[] = []; - let order = 1; - - cardsToUse.forEach(card => { - const wordCEFR = card.difficultyLevel || 'A2'; - const allTestTypes = getReviewTypesByCEFR(userCEFR, wordCEFR); - - // 找出該詞卡已完成的測驗類型 - 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, - testName: getModeLabel(testType), - isCompleted: false, - isCurrent: false, - order - }); - order++; - }); - }); - - if (remainingTestItems.length === 0) { - console.log('🎉 所有測驗都已完成!'); - setShowComplete(true); - return; - } - - console.log('📝 剩餘測驗項目:', remainingTestItems.length, '個'); - - // 設置狀態 - setTotalTests(remainingTestItems.length); - setTestItems(remainingTestItems); - setCurrentTestItemIndex(0); - setCompletedTests(0); - - // 找到第一個測驗項目對應的詞卡 - const firstTestItem = remainingTestItems[0]; - const firstCard = cardsToUse.find(c => c.id === firstTestItem.cardId); - - if (firstCard && firstTestItem) { - setCurrentCard(firstCard); - setCurrentCardIndex(cardsToUse.findIndex(c => c.id === firstCard.id)); - - // 設置測驗模式為第一個測驗的類型 - const modeMapping: { [key: string]: typeof mode } = { - 'flip-memory': 'flip-memory', - 'vocab-choice': 'vocab-choice', - 'vocab-listening': 'vocab-listening', - 'sentence-fill': 'sentence-fill', - 'sentence-reorder': 'sentence-reorder', - 'sentence-speaking': 'sentence-speaking', - 'sentence-listening': 'sentence-listening' - }; - - const selectedMode = modeMapping[firstTestItem.testType] || 'flip-memory'; - setMode(selectedMode); - setIsAutoSelecting(false); - - // 標記第一個測驗項目為當前狀態 - setTestItems(prev => - prev.map((item, index) => - index === 0 - ? { ...item, isCurrent: true } - : item - ) - ); - - console.log(`🎯 恢復到未完成測驗: ${firstCard.word} - ${firstTestItem.testType}`); - } - } else { - // 沒有到期詞卡 - console.log('❌ API回應:', { - success: apiResult.success, - dataExists: !!apiResult.data, - dataLength: apiResult.data?.length, - error: apiResult.error - }); - setDueCards([]); - setCurrentCard(null); - setShowNoDueCards(true); - } - - } catch (error) { - console.error('💥 載入到期詞卡失敗:', error); - setDueCards([]); - setCurrentCard(null); - setShowNoDueCards(true); - } finally { - setIsLoadingCard(false); - } - } - - // 智能載入下一張卡片並自動選擇模式 - const loadNextCardWithAutoMode = async (cardIndex: number) => { - try { - setIsAutoSelecting(true); - - // 等待dueCards載入完成 - if (dueCards.length === 0) { - console.log('等待詞卡載入...'); - return; - } - - const card = dueCards[cardIndex]; - if (!card) { - setShowComplete(true); - setIsAutoSelecting(false); - return; - } - - setCurrentCard(card); - setCurrentCardIndex(cardIndex); - - // 系統自動選擇最適合的複習模式 - const selectedMode = await selectOptimalReviewMode(card); - setMode(selectedMode); - - // 重置所有答題狀態 - resetAllStates(); - - console.log(`載入卡片: ${card.word}, 選擇模式: ${selectedMode}`); - - } catch (error) { - console.error('載入卡片失敗:', error); - } finally { - setIsAutoSelecting(false); - } - } - - // 系統自動選擇最適合的複習模式 - const selectOptimalReviewMode = async (card: ExtendedFlashcard): Promise => { - try { - // 使用CEFR字符串進行智能選擇 - const userCEFRLevel = localStorage.getItem('userEnglishLevel') || 'A2'; - const wordCEFRLevel = card.difficultyLevel || 'A2'; - - console.log(`CEFR智能選擇: 用戶${userCEFRLevel} vs 詞彙${wordCEFRLevel}`); - - const apiResult = await flashcardsService.getOptimalReviewMode(card.id, userCEFRLevel, wordCEFRLevel); - - if (apiResult.success && apiResult.data?.selectedMode) { - const selectedMode = apiResult.data.selectedMode; - console.log(`後端智能選擇: ${selectedMode}`); - - // 映射到前端模式名稱 - const modeMapping: { [key: string]: typeof mode } = { - 'flip-memory': 'flip-memory', - 'vocab-choice': 'vocab-choice', - 'vocab-listening': 'vocab-listening', - 'sentence-fill': 'sentence-fill', - 'sentence-reorder': 'sentence-reorder', - 'sentence-speaking': 'sentence-speaking', - 'sentence-listening': 'sentence-listening' - }; - - return modeMapping[selectedMode] || 'flip-memory'; - } else { - console.log('後端API失敗,使用前端邏輯'); - } - } catch (error) { - console.error('智能選擇API錯誤:', error); - } - - // 備用: 使用前端CEFR邏輯 - const userCEFRLevel = localStorage.getItem('userEnglishLevel') || 'A2'; - const wordCEFRLevel = card.difficultyLevel || 'A2'; - const availableModes = getReviewTypesByCEFR(userCEFRLevel, wordCEFRLevel); - - const modeMapping: { [key: string]: typeof mode } = { - 'flip-memory': 'flip-memory', - 'vocab-choice': 'vocab-choice', - 'vocab-listening': 'vocab-listening', - 'sentence-fill': 'sentence-fill', - 'sentence-reorder': 'sentence-reorder', - 'sentence-speaking': 'sentence-speaking', - 'sentence-listening': 'sentence-listening' - }; - - const selectedType = availableModes[0] || 'flip-memory'; - console.log(`前端CEFR邏輯選擇: ${selectedType}`); - return modeMapping[selectedType] || 'flip-memory'; - } - - // 前端CEFR備用選擇邏輯 - const getReviewTypesByCEFR = (userCEFR: string, wordCEFR: string): string[] => { - const userLevel = getCEFRToLevel(userCEFR); - const wordLevel = getCEFRToLevel(wordCEFR); - const difficulty = wordLevel - userLevel; - - if (userCEFR === 'A1') { - return ['flip-memory', 'vocab-choice', 'vocab-listening']; - } else if (difficulty < -10) { - return ['sentence-reorder', 'sentence-fill']; - } else if (difficulty >= -10 && difficulty <= 10) { - return ['sentence-fill', 'sentence-reorder', 'sentence-speaking']; - } else { - return ['flip-memory', 'vocab-choice']; - } - } - - // CEFR轉換為數值 (前端計算用) - const getCEFRToLevel = (cefr: string): number => { - const mapping: { [key: string]: number } = { - 'A1': 20, 'A2': 35, 'B1': 50, 'B2': 65, 'C1': 80, 'C2': 95 - }; - return mapping[cefr] || 50; - } - - // 計算每張詞卡的測驗數量 - const calculateTestsForCard = (userCEFR: string, wordCEFR: string): number => { - const userLevel = getCEFRToLevel(userCEFR); - const wordLevel = getCEFRToLevel(wordCEFR); - const difficulty = wordLevel - userLevel; - - if (userCEFR === 'A1') { - return 3; // A1學習者:翻卡、選擇、聽力 - } else if (difficulty < -10) { - return 2; // 簡單詞彙:填空、重組 - } else if (difficulty >= -10 && difficulty <= 10) { - return 3; // 適中詞彙:填空、重組、口說 - } else { - return 2; // 困難詞彙:翻卡、選擇 - } - } - - // 取得當前學習情境 - const getCurrentContext = (userCEFR: string, wordCEFR: string): string => { - const userLevel = getCEFRToLevel(userCEFR); - const wordLevel = getCEFRToLevel(wordCEFR); - const difficulty = wordLevel - userLevel; - - if (userCEFR === 'A1') return 'A1學習者'; - if (difficulty < -10) return '簡單詞彙'; - if (difficulty >= -10 && difficulty <= 10) return '適中詞彙'; - return '困難詞彙'; - } - - // 生成完整四情境對照表數據 - const generateContextTable = (currentUserCEFR: string, currentWordCEFR: string) => { - const currentContext = getCurrentContext(currentUserCEFR, currentWordCEFR); - - const contexts = [ - { - type: 'A1學習者', - icon: '🛡️', - reviewTypes: ['🔄翻卡記憶', '✅詞彙選擇', '🎧詞彙聽力'], - purpose: '建立基礎信心', - condition: '用戶等級 = A1', - description: '初學者保護機制,使用最基礎的3種題型' - }, - { - type: '簡單詞彙', - icon: '🎯', - reviewTypes: ['✏️例句填空', '🔀例句重組'], - purpose: '應用練習', - condition: '用戶等級 > 詞彙等級', - description: '詞彙對您較簡單,重點練習拼寫和語法應用' - }, - { - type: '適中詞彙', - icon: '⚖️', - reviewTypes: ['✏️例句填空', '🔀例句重組', '🗣️例句口說'], - purpose: '全方位練習', - condition: '用戶等級 ≈ 詞彙等級', - description: '詞彙難度適中,進行聽說讀寫全方位練習' - }, - { - type: '困難詞彙', - icon: '📚', - reviewTypes: ['🔄翻卡記憶', '✅詞彙選擇'], - purpose: '基礎重建', - condition: '用戶等級 < 詞彙等級', - description: '詞彙對您較困難,回歸基礎重建記憶' - } - ]; - - return contexts.map(context => ({ - ...context, - isCurrent: context.type === currentContext - })); - } - - // 取得題型圖標 - const getModeIcon = (mode: string): string => { - const icons: { [key: string]: string } = { - 'flip-memory': '🔄', - 'vocab-choice': '✅', - 'vocab-listening': '🎧', - 'sentence-listening': '👂', - 'sentence-fill': '✏️', - 'sentence-reorder': '🔀', - 'sentence-speaking': '🗣️' - }; - return icons[mode] || '📝'; - } - - // 取得題型中文名稱 - const getModeLabel = (mode: string): string => { - const labels: { [key: string]: string } = { - 'flip-memory': '翻卡記憶', - 'vocab-choice': '詞彙選擇', - 'vocab-listening': '詞彙聽力', - 'sentence-listening': '例句聽力', - 'sentence-fill': '例句填空', - 'sentence-reorder': '例句重組', - 'sentence-speaking': '例句口說' - }; - return labels[mode] || mode; - } - - // 生成測驗項目列表 - const generateTestItems = (cards: ExtendedFlashcard[], userCEFR: string): TestItem[] => { - const items: TestItem[] = []; - let order = 1; - - cards.forEach(card => { - const wordCEFR = card.difficultyLevel || 'A2'; - const testTypes = getReviewTypesByCEFR(userCEFR, wordCEFR); - - testTypes.forEach(testType => { - items.push({ - id: `${card.id}-${testType}`, - cardId: card.id, - word: card.word, - testType, - testName: getModeLabel(testType), - isCompleted: false, - isCurrent: false, - order - }); - order++; - }); - }); - - return items; - } - - // 按詞卡分組測驗項目 - const groupTestItemsByCard = (items: TestItem[]): CardTestGroup[] => { - const grouped = items.reduce((acc, item) => { - const cardId = item.cardId; - if (!acc[cardId]) { - const card = dueCards.find(c => c.id === cardId); - const userCEFR = localStorage.getItem('userEnglishLevel') || 'A2'; - const wordCEFR = card?.difficultyLevel || 'A2'; - - acc[cardId] = { - cardId, - word: item.word, - context: getCurrentContext(userCEFR, wordCEFR), - tests: [] - }; - } - acc[cardId].tests.push(item); - return acc; - }, {} as Record); - - return Object.values(grouped); - } - - // 初始化詞卡複習會話 - const initializeCardReviewSession = (card: ExtendedFlashcard): CardReviewSession => { - const userCEFR = localStorage.getItem('userEnglishLevel') || 'A2'; - const wordCEFR = card.difficultyLevel || 'A2'; - const plannedTests = getReviewTypesByCEFR(userCEFR, wordCEFR); - - return { - cardId: card.id, - word: card.word, - plannedTests, - completedTests: [], - startedAt: new Date(), - isCompleted: false - }; - } - - // 開始詞卡複習會話 - const startCardReviewSession = (card: ExtendedFlashcard) => { - const session = initializeCardReviewSession(card); - setCurrentCardSession(session); - - // 更新會話映射 - setCardReviewSessions(prev => new Map(prev.set(card.id, session))); - - console.log(`🎯 開始詞卡複習會話: ${card.word}`, { - plannedTests: session.plannedTests, - totalTests: session.plannedTests.length - }); - } - - // 檢查詞卡是否已完成所有測驗 - const isCardReviewCompleted = (cardId: string): boolean => { - const session = cardReviewSessions.get(cardId); - return session?.isCompleted || false; - } - - // 獲取詞卡的下一個測驗類型 - const getNextTestTypeForCard = (cardId: string): string | null => { - const session = cardReviewSessions.get(cardId); - if (!session) return null; - - const completedTestTypes = session.completedTests.map(t => t.testType); - const nextTestType = session.plannedTests.find(testType => - !completedTestTypes.includes(testType) - ); - - return nextTestType || null; - } - - // 重置所有答題狀態 - const resetAllStates = () => { - setIsFlipped(false); - setSelectedAnswer(null); - setShowResult(false); - setFillAnswer(''); - setShowHint(false); - setShuffledWords([]); - setArrangedWords([]); - setReorderResult(null); - setQuizOptions([]); - } - - // Quiz options generation - useEffect(() => { - if (!currentCard) return; - - const currentWord = currentCard.word; - - // Generate quiz options with current word and other words - const otherWords = dueCards - .filter(card => card.id !== currentCard.id) - .map(card => card.word); - - // 優先從其他詞卡生成選項,必要時使用備用詞彙 - const selectedOtherWords: string[] = []; - - // 從其他詞卡取得選項 - for (const word of otherWords) { - if (selectedOtherWords.length >= 3) break; - if (word !== currentWord && !selectedOtherWords.includes(word)) { - selectedOtherWords.push(word); - } - } - - // 如果詞卡不足,補充基礎詞彙 - if (selectedOtherWords.length < 3) { - const backupWords = ['important', 'beautiful', 'interesting', 'difficult', 'wonderful', 'excellent']; - for (const word of backupWords) { - if (selectedOtherWords.length >= 3) break; - if (word !== currentWord && !selectedOtherWords.includes(word)) { - selectedOtherWords.push(word); + // 初始化學習會話 + const initializeLearnSession = async () => { + await reviewSession.loadDueCards() + + if (reviewSession.dueCards.length > 0) { + const cardIds = reviewSession.dueCards.map(c => c.id) + const completedTests = await testQueue.getCompletedTestsForCards(cardIds) + testQueue.initializeTestQueue(reviewSession.dueCards, completedTests) + + if (testQueue.testItems.length > 0) { + const firstTestItem = testQueue.testItems[0] + const firstCard = reviewSession.dueCards.find(c => c.id === firstTestItem.cardId) + + if (firstCard) { + reviewSession.setCurrentCard(firstCard) + reviewSession.setMode(firstTestItem.testType as any) + reviewSession.setIsAutoSelecting(false) } } } - - // 確保有4個選項:當前詞彙 + 3個其他選項 - const options = [currentWord, ...selectedOtherWords.slice(0, 3)].sort(() => Math.random() - 0.5); - setQuizOptions(options); - - // Reset quiz state when card changes - setSelectedAnswer(null); - setShowResult(false); - }, [currentCard, dueCards]) - - // Sentence options generation for sentence listening - useEffect(() => { - if (!currentCard || mode !== 'sentence-listening') return; - - const currentSentence = currentCard.example; - - // Generate sentence options with current sentence and other sentences - const otherSentences = dueCards - .filter(card => card.id !== currentCard.id) - .map(card => card.example); - - // 優先從其他詞卡的例句生成選項 - const selectedOtherSentences: string[] = []; - - // 從其他詞卡的例句取得選項 - for (const sentence of otherSentences) { - if (selectedOtherSentences.length >= 3) break; - if (sentence !== currentSentence && !selectedOtherSentences.includes(sentence)) { - selectedOtherSentences.push(sentence); - } - } - - // 如果例句不足,使用基礎例句補充 - if (selectedOtherSentences.length < 3) { - const backupSentences = [ - 'This is a very important decision.', - 'The weather looks beautiful today.', - 'We need to find a good solution.', - 'Learning English can be interesting.' - ]; - - for (const sentence of backupSentences) { - if (selectedOtherSentences.length >= 3) break; - if (sentence !== currentSentence && !selectedOtherSentences.includes(sentence)) { - selectedOtherSentences.push(sentence); - } - } - } - - // 確保有4個選項:當前例句 + 3個其他選項 - const options = [currentSentence, ...selectedOtherSentences.slice(0, 3)].sort(() => Math.random() - 0.5); - setSentenceOptions(options); - - }, [currentCard, dueCards, mode]) - - // Initialize sentence reorder when card changes or mode switches to sentence-reorder - useEffect(() => { - if (mode === 'sentence-reorder' && currentCard) { - const words = currentCard.example.split(/\s+/).filter(word => word.length > 0) - const shuffled = [...words].sort(() => Math.random() - 0.5) - setShuffledWords(shuffled) - setArrangedWords([]) - // 只在卡片或模式切換時重置結果,不在其他狀態變化時重置 - if (reorderResult !== null) { - setReorderResult(null) - } - } - }, [currentCard, mode]) // 移除reorderResult依賴,避免循環重置 - - // Sentence reorder handlers - const handleWordClick = (word: string) => { - // Move word from shuffled to arranged - setShuffledWords(prev => prev.filter(w => w !== word)) - setArrangedWords(prev => [...prev, word]) - setReorderResult(null) - } - - const handleRemoveFromArranged = (word: string) => { - setArrangedWords(prev => prev.filter(w => w !== word)) - setShuffledWords(prev => [...prev, word]) - setReorderResult(null) - } - - const handleCheckReorderAnswer = async () => { - if (!currentCard) return; - - const userSentence = arrangedWords.join(' ') - const correctSentence = currentCard.example - const isCorrect = userSentence.toLowerCase().trim() === correctSentence.toLowerCase().trim() - setReorderResult(isCorrect) - - // 更新分數 - setScore(prev => ({ - correct: isCorrect ? prev.correct + 1 : prev.correct, - total: prev.total + 1 - })) - - // 記錄測驗結果 - await recordTestResult(isCorrect, userSentence); - } - - const handleResetReorder = () => { - if (!currentCard) return; - - const words = currentCard.example.split(/\s+/).filter(word => word.length > 0) - const shuffled = [...words].sort(() => Math.random() - 0.5) - setShuffledWords(shuffled) - setArrangedWords([]) - setReorderResult(null) - } - - const handleFlip = () => { - setIsFlipped(!isFlipped) - } - - // 移動到下一個測驗或下一張詞卡 - const handleNext = async () => { - if (!currentCard || !currentCardSession) return; - - // 檢查當前詞卡是否還有未完成的測驗 - const nextTestType = getNextTestTypeForCard(currentCard.id); - - if (nextTestType) { - // 當前詞卡還有測驗未完成,切換到下一個測驗類型 - const modeMapping: { [key: string]: typeof mode } = { - 'flip-memory': 'flip-memory', - 'vocab-choice': 'vocab-choice', - 'vocab-listening': 'vocab-listening', - 'sentence-fill': 'sentence-fill', - 'sentence-reorder': 'sentence-reorder', - 'sentence-speaking': 'sentence-speaking', - 'sentence-listening': 'sentence-listening' - }; - - const nextMode = modeMapping[nextTestType] || 'flip-memory'; - setMode(nextMode); - resetAllStates(); - - // 更新測驗項目的當前狀態 - setTestItems(prev => - prev.map(item => - item.cardId === currentCard.id && item.testType === nextTestType - ? { ...item, isCurrent: true } - : { ...item, isCurrent: false } - ) - ); - - console.log(`🔄 切換到下一個測驗: ${nextTestType} for ${currentCard.word}`); - } else { - // 當前詞卡的所有測驗都已完成,移動到下一張詞卡 - if (currentCardIndex < dueCards.length - 1) { - const nextCardIndex = currentCardIndex + 1; - const nextCard = dueCards[nextCardIndex]; - - // 開始新詞卡的複習會話 - startCardReviewSession(nextCard); - - await loadNextCardWithAutoMode(nextCardIndex); - console.log(`➡️ 移動到下一張詞卡: ${nextCard.word}`); - } else { - // 所有詞卡都已完成 - setShowComplete(true); - console.log(`🎉 所有詞卡復習完成!`); - } - } - } - - const handlePrevious = async () => { - // 暫時保持簡單的向前導航 - if (currentCardIndex > 0) { - await loadNextCardWithAutoMode(currentCardIndex - 1); - } } + // 答題處理 const handleQuizAnswer = async (answer: string) => { - if (showResult || !currentCard) return - - setSelectedAnswer(answer) - setShowResult(true) - - const isCorrect = answer === currentCard.word - setScore(prev => ({ - correct: isCorrect ? prev.correct + 1 : prev.correct, - total: prev.total + 1 - })) - - // 記錄測驗結果到資料庫 - await recordTestResult(isCorrect, answer); - } - - // 記錄測驗結果到資料庫(立即保存) - const recordTestResult = async (isCorrect: boolean, userAnswer?: string, confidenceLevel?: number) => { - if (!currentCard) return; - - // 檢查認證狀態 - const token = localStorage.getItem('auth_token'); - if (!token) { - console.error('❌ 未找到認證token,請重新登入'); - return; - } - - try { - console.log('🔄 開始記錄測驗結果到資料庫...', { - flashcardId: currentCard.id, - testType: mode, - word: currentCard.word, - isCorrect, - hasToken: !!token - }); - - // 立即記錄到資料庫 - const result = await flashcardsService.recordTestCompletion({ - flashcardId: currentCard.id, - testType: mode, - isCorrect, - userAnswer, - confidenceLevel, - responseTimeMs: 2000 - }); - - if (result.success) { - console.log('✅ 測驗結果已記錄到資料庫:', mode, 'for', currentCard.word); - - // 更新本地UI狀態 - setCompletedTests(prev => prev + 1); - - // 標記當前測驗項目為完成 - setTestItems(prev => - prev.map((item, index) => - index === currentTestItemIndex - ? { ...item, isCompleted: true, isCurrent: false } - : item - ) - ); - - // 移到下一個測驗項目 - setCurrentTestItemIndex(prev => prev + 1); - - // 檢查是否還有剩餘測驗 - setTimeout(() => { - loadNextUncompletedTest(); - }, 1500); - - } else { - console.error('❌ 記錄測驗結果失敗:', result.error); - if (result.error?.includes('Test already completed') || result.error?.includes('already completed')) { - console.log('⚠️ 測驗已完成,跳到下一個'); - loadNextUncompletedTest(); - } else { - // 其他錯誤,先更新UI狀態避免卡住 - setCompletedTests(prev => prev + 1); - setCurrentTestItemIndex(prev => prev + 1); - setTimeout(() => { - loadNextUncompletedTest(); - }, 1500); - } - } - } catch (error) { - console.error('💥 記錄測驗結果異常:', error); - // 即使出錯也更新進度,避免卡住 - setCompletedTests(prev => prev + 1); - setCurrentTestItemIndex(prev => prev + 1); - setTimeout(() => { - loadNextUncompletedTest(); - }, 1500); - } - } - - // 載入下一個未完成的測驗 - const loadNextUncompletedTest = () => { - if (currentTestItemIndex + 1 < testItems.length) { - // 還有測驗項目 - const nextTestItem = testItems[currentTestItemIndex + 1]; - const nextCard = dueCards.find(c => c.id === nextTestItem.cardId); - - if (nextCard) { - setCurrentCard(nextCard); - setCurrentCardIndex(dueCards.findIndex(c => c.id === nextCard.id)); - - // 設置下一個測驗類型 - const modeMapping: { [key: string]: typeof mode } = { - 'flip-memory': 'flip-memory', - 'vocab-choice': 'vocab-choice', - 'vocab-listening': 'vocab-listening', - 'sentence-fill': 'sentence-fill', - 'sentence-reorder': 'sentence-reorder', - 'sentence-speaking': 'sentence-speaking', - 'sentence-listening': 'sentence-listening' - }; - - const nextMode = modeMapping[nextTestItem.testType] || 'flip-memory'; - setMode(nextMode); - resetAllStates(); - - // 更新測驗項目的當前狀態 - setTestItems(prev => - prev.map((item, index) => - index === currentTestItemIndex + 1 - ? { ...item, isCurrent: true } - : { ...item, isCurrent: false } - ) - ); - - console.log(`🔄 載入下一個測驗: ${nextCard.word} - ${nextTestItem.testType}`); - } - } else { - // 所有測驗完成 - console.log('🎉 所有測驗完成!'); - setShowComplete(true); - } - } - - // 完成詞卡複習並提交到後端 - const completeCardReview = async (session: CardReviewSession) => { - try { - // 計算綜合表現指標 - const correctCount = session.completedTests.filter(t => t.isCorrect).length; - const totalTests = session.completedTests.length; - const accuracy = totalTests > 0 ? correctCount / totalTests : 0; - - // 計算平均信心等級(用於翻卡記憶測驗) - const confidenceTests = session.completedTests.filter(t => t.confidenceLevel !== undefined); - const avgConfidence = confidenceTests.length > 0 - ? confidenceTests.reduce((sum, t) => sum + (t.confidenceLevel || 3), 0) / confidenceTests.length - : 3; - - // 計算平均答題時間 - const avgResponseTime = session.completedTests.reduce((sum, t) => sum + t.responseTimeMs, 0) / totalTests; - - // 確定主要測驗類型(用於後端SM2算法) - const primaryTestType = session.completedTests[0]?.testType || 'flip-memory'; - - console.log(`🔥 提交詞卡完整復習結果:`, { - word: session.word, - accuracy: `${Math.round(accuracy * 100)}%`, - avgConfidence, - avgResponseTime: `${avgResponseTime}ms`, - primaryTestType, - completedTests: session.completedTests.length - }); - - // 提交到後端 - const result = await flashcardsService.submitReview(session.cardId, { - isCorrect: accuracy >= 0.7, // 70%以上正確率視為通過 - confidenceLevel: Math.round(avgConfidence), - questionType: primaryTestType, - userAnswer: `綜合${totalTests}個測驗,正確率${Math.round(accuracy * 100)}%`, - timeTaken: Math.round(avgResponseTime) - }); - - if (result.success && result.data) { - console.log('✅ 詞卡復習結果提交成功:', result.data); - - // 更新詞卡的熟悉度等資訊 - if (currentCard && currentCard.id === session.cardId) { - setCurrentCard(prev => prev ? { - ...prev, - masteryLevel: result.data!.masteryLevel, - nextReviewDate: result.data!.nextReviewDate - } : null); - } - - // 增加已完成詞卡數量 - setCompletedCards(prev => prev + 1); - - console.log(`🎉 詞卡 ${session.word} 復習完成!新熟悉度: ${result.data.masteryLevel}%, 下次復習: ${result.data.nextReviewDate}`); - } else { - console.error('詞卡復習結果提交失敗:', result.error); - } - - } catch (error) { - console.error('完成詞卡復習時發生錯誤:', error); - } + if (testAnswering.showResult || !reviewSession.currentCard) return + testAnswering.setSelectedAnswer(answer) + testAnswering.setShowResult(true) + const isCorrect = testAnswering.checkVocabChoice(reviewSession.currentCard.word) + progressTracker.updateScore(isCorrect) + await testQueue.recordTestResult(isCorrect, answer) } const handleFillAnswer = async () => { - if (showResult || !currentCard) return - - setShowResult(true) - - const isCorrect = fillAnswer.toLowerCase().trim() === currentCard.word.toLowerCase() - setScore(prev => ({ - correct: isCorrect ? prev.correct + 1 : prev.correct, - total: prev.total + 1 - })) - - // 記錄測驗結果 - await recordTestResult(isCorrect, fillAnswer); + if (testAnswering.showResult || !reviewSession.currentCard) return + testAnswering.setShowResult(true) + const isCorrect = testAnswering.checkSentenceFill(reviewSession.currentCard.word) + progressTracker.updateScore(isCorrect) + await testQueue.recordTestResult(isCorrect, testAnswering.fillAnswer) } - const handleListeningAnswer = async (answer: string) => { - if (showResult || !currentCard) return - - setSelectedAnswer(answer) - setShowResult(true) - - const isCorrect = answer === currentCard.word - setScore(prev => ({ - correct: isCorrect ? prev.correct + 1 : prev.correct, - total: prev.total + 1 - })) - - // 記錄測驗結果 - await recordTestResult(isCorrect, answer); + const handleConfidenceLevel = async (level: number) => { + if (!reviewSession.currentCard) return + testAnswering.setShowResult(true) + progressTracker.updateScore(true) + await testQueue.recordTestResult(true, undefined, level) } - const handleSpeakingAnswer = async (transcript: string) => { - if (!currentCard) return - - setShowResult(true) - - const isCorrect = transcript.toLowerCase().includes(currentCard.word.toLowerCase()) - setScore(prev => ({ - correct: isCorrect ? prev.correct + 1 : prev.correct, - total: prev.total + 1 - })) - - // 記錄測驗結果 - await recordTestResult(isCorrect, transcript); + const handleReorderAnswer = async () => { + if (!reviewSession.currentCard) return + const isCorrect = testAnswering.checkSentenceReorder(reviewSession.currentCard.example) + testAnswering.setReorderResult(isCorrect) + progressTracker.updateScore(isCorrect) + await testQueue.recordTestResult(isCorrect, testAnswering.arrangedWords.join(' ')) } - const handleSentenceListeningAnswer = async (answer: string) => { - if (showResult || !currentCard) return + const handleNext = () => { + testQueue.loadNextUncompletedTest() + testAnswering.resetAllAnsweringStates() - setSelectedAnswer(answer) - setShowResult(true) + if (testQueue.currentTestItemIndex < testQueue.testItems.length) { + const nextTestItem = testQueue.testItems[testQueue.currentTestItemIndex] + const nextCard = reviewSession.dueCards.find(c => c.id === nextTestItem.cardId) - const isCorrect = answer === currentCard.example - setScore(prev => ({ - correct: isCorrect ? prev.correct + 1 : prev.correct, - total: prev.total + 1 - })) - - // 記錄測驗結果 - await recordTestResult(isCorrect, answer); - } - - const handleReportSubmit = () => { - console.log('Report submitted:', { - card: reportingCard, - reason: reportReason - }) - setShowReportModal(false) - setReportReason('') - setReportingCard(null) + if (nextCard) { + reviewSession.setCurrentCard(nextCard) + reviewSession.setMode(nextTestItem.testType as any) + } + } else { + reviewSession.setShowComplete(true) + } } const handleRestart = async () => { - setScore({ correct: 0, total: 0 }) - setCompletedTests(0) - setTotalTests(0) - setTestItems([]) - setCurrentTestItemIndex(0) - setShowComplete(false) - setShowNoDueCards(false) - await loadDueCards(); // 重新載入到期詞卡 + progressTracker.resetScore() + testQueue.resetTestQueue() + await reviewSession.restart() } - // Show loading screen until mounted or while loading cards - if (!mounted || isLoadingCard) { - return ( -
-
- {isAutoSelecting ? '系統正在選擇最適合的複習方式...' : '載入中...'} -
-
- ) + // 渲染邏輯 + if (!mounted || reviewSession.isLoadingCard) { + return } - // Show no due cards screen - if (showNoDueCards) { - return ( -
- -
-
-
📚
-

- 今日學習已完成! -

-

- 目前沒有到期需要複習的詞卡。您可以: -

- -
-
-
💡 建議行動
-
    -
  • • 前往詞卡管理頁面新增詞卡
  • -
  • • 查看學習統計和進度
  • -
  • • 調整學習目標和設定
  • -
-
-
- -
- - -
- - -
-
-
- ) + if (reviewSession.showNoDueCards) { + return } - // Show current card interface - if (!currentCard) { - return ( -
-
載入詞卡中...
-
- ) + if (!reviewSession.currentCard) { + return } return (
- {/* Navigation */}
- {/* Progress Bar */} -
-
- 學習進度 -
- {/* 詞卡進度 */} -
- 詞卡: - {completedCards} - / - {dueCards.length} - {dueCards.length > 0 && ( - - ({Math.round((completedCards / dueCards.length) * 100)}%) - - )} -
+ progressTracker.setShowTaskListModal(true)} + /> - {/* 測驗進度 */} - + testAnswering.setShowHint(!testAnswering.showHint)} + onFlip={() => testAnswering.setIsFlipped(!testAnswering.isFlipped)} + onConfidenceLevel={handleConfidenceLevel} + onWordClick={testAnswering.addWordToArranged} + onRemoveFromArranged={testAnswering.removeWordFromArranged} + onCheckReorderAnswer={handleReorderAnswer} + onResetReorder={() => testAnswering.resetReorderTest(reviewSession.currentCard?.example || '')} + onReportError={() => { + setReportingCard(reviewSession.currentCard) + setShowReportModal(true) + }} + onNavigate={(direction) => direction === 'next' ? handleNext() : () => {}} + setModalImage={setModalImage} + /> + + progressTracker.setShowTaskListModal(false)} + testItems={testQueue.testItems} + completedTests={testQueue.completedTests} + totalTests={testQueue.totalTests} + /> + + {reviewSession.showComplete && ( + router.push('/dashboard')} + /> + )} + + {modalImage && ( +
setModalImage(null)}> +
+ 放大圖片 +
+ )} - {/* 雙層進度條 */} -
- {/* 詞卡進度條 */} -
- 詞卡 -
-
0 ? (completedCards / dueCards.length) * 100 : 0}%` }} - >
-
- - {dueCards.length > 0 ? Math.round((completedCards / dueCards.length) * 100) : 0}% - -
- - {/* 測驗進度條 */} -
- 測驗 -
setShowTaskListModal(true)} - title="點擊查看詳細任務清單" - > -
0 ? (completedTests / totalTests) * 100 : 0}%` }} - >
-
- - {totalTests > 0 ? Math.round((completedTests / totalTests) * 100) : 0}% - -
-
-
- - {mode === 'flip-memory' ? ( - /* Flip Card Mode */ -
- {/* Error Report Button for Flip Mode */} -
- -
- -
-
- {/* Front */} -
-
- {/* Title and Instructions */} -
-

- 翻卡記憶 -

- - {currentCard.difficultyLevel} - -
- - {/* Instructions Test Action */} -

- 點擊卡片翻面,根據你對單字的熟悉程度進行自我評估: -

- - {/* Word Display */} -
-
-

- {currentCard.word} -

-
- - {currentCard.pronunciation} - - -
-
-
-
-
- - {/* Back */} -
-
- {/* Content Sections */} -
- {/* Definition */} -
-

定義

-

{currentCard.definition}

-
- - {/* Example */} -
-

例句

-
-

"{currentCard.example}"

-
- -
-
-

"{currentCard.exampleTranslation}"

-
- - {/* Synonyms */} -
-

同義詞

-
- {(currentCard.synonyms || []).map((synonym, index) => ( - - {synonym} - - ))} -
-
-
-
-
-
-
- - {/* Navigation */} -
- - -
-
- ) : mode === 'vocab-choice' ? ( - /* Vocab Choice Mode - 詞彙選擇 */ -
- {/* Error Report Button for Quiz Mode */} -
- -
- -
- {/* Title in top-left */} -
-

- 詞彙選擇 -

- - {currentCard.difficultyLevel} - -
- - {/* Instructions Test Action */} -

- 請選擇符合上述定義的英文詞彙: -

- -
-
-

定義

-

{currentCard.definition}

-
- -
- -
- {quizOptions.map((option, idx) => ( - - ))} -
- - {showResult && ( -
-

- {selectedAnswer === currentCard.word ? '正確!' : '錯誤!'} -

- - {selectedAnswer !== currentCard.word && ( -
-

- 正確答案是:{currentCard.word} -

-
- )} - -
-
-
- 發音: - {currentCard.pronunciation} - -
-
-
-
- )} -
- - {/* Navigation */} -
- - -
-
- ) : mode === 'sentence-fill' ? ( - /* Fill in the Blank Mode - 填空題 */ -
- {/* Error Report Button for Fill Mode */} -
- -
- -
- {/* Title in top-left */} -
-

- 例句填空 -

- - {currentCard.difficultyLevel} - -
- - {/* Example Image */} - {currentCard.exampleImage && ( -
-
- Example illustration setModalImage(currentCard.exampleImage || null)} - /> -
-
- )} - - {/* Instructions Test Action */} -

- 請點擊例句中的空白處輸入正確的單字: -

- - {/* Example Sentence with Blanks */} -
-
-
- {currentCard.example.split(new RegExp(`(${currentCard.word})`, 'gi')).map((part, index) => { - const isTargetWord = part.toLowerCase() === currentCard.word.toLowerCase(); - return isTargetWord ? ( - - setFillAnswer(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter' && !showResult && fillAnswer.trim()) { - handleFillAnswer() - } - }} - placeholder="" - disabled={showResult} - className={`inline-block px-2 py-1 text-center bg-transparent focus:outline-none disabled:bg-gray-100 ${ - fillAnswer - ? 'border-b-2 border-blue-500' - : 'border-b-2 border-dashed border-gray-400 focus:border-blue-400 focus:border-solid' - }`} - style={{ width: `${Math.max(100, Math.max(currentCard.word.length * 12, fillAnswer.length * 12 + 20))}px` }} - /> - {!fillAnswer && ( - - ____ - - )} - - ) : ( - {part} - ); - })} -
-
-
- - {/* Action Buttons */} -
- {!showResult && fillAnswer.trim() && ( - - )} - -
- - {/* Hint Section */} - {showHint && ( -
-

詞彙定義:

-

{currentCard.definition}

-
- )} - - {showResult && ( -
-

- {fillAnswer.toLowerCase().trim() === currentCard.word.toLowerCase() ? '正確!' : '錯誤!'} -

- - {fillAnswer.toLowerCase().trim() !== currentCard.word.toLowerCase() && ( -
-

- 正確答案是:{currentCard.word} -

-
- )} - -
-
-

- {currentCard.pronunciation} - -

-
- -
-
-
-
- )} -
- - {/* Navigation */} -
- - -
-
- ) : mode === 'vocab-listening' ? ( - /* Listening Test Mode - 聽力測試 */ -
- {/* Error Report Button for Listening Mode */} -
- -
- -
- {/* Title in top-left */} -
-

- 詞彙聽力 (暫時不上線) -

- - {currentCard.difficultyLevel} - -
- - {/* Instructions Test Action */} -

- 請聽發音並選擇正確的英文單字: -

- - {/* Content Sections */} -
- {/* Audio */} -
-

發音

-
- {currentCard.pronunciation} - -
-
-
- - {/* Word Options */} -
- {[currentCard.word, 'determine', 'achieve', 'consider'].map((word) => ( - - ))} -
- - {showResult && ( -
-

- {selectedAnswer === currentCard.word ? '正確!' : '錯誤!'} -

- {selectedAnswer !== currentCard.word && ( -
-

- 正確答案是:{currentCard.word} -

-
- 發音:{currentCard.pronunciation} - -
-
- )} -
- )} -
- - {/* Navigation */} -
- - -
-
- ) : mode === 'sentence-speaking' ? ( - /* Speaking Test Mode - 口說測試 */ -
- {/* Error Report Button for Speaking Mode */} -
- -
- -
- {/* Title in top-left */} -
-

- 例句口說 -

- - {currentCard.difficultyLevel} - -
- -
- { - // 簡化處理:直接顯示結果 - handleSpeakingAnswer(currentCard.example) - }} - /> -
- - {showResult && ( -
-

- 錄音完成! -

-

- 系統正在評估你的發音... -

-
- )} -
- - {/* Navigation */} -
- - -
-
- ) : mode === 'sentence-listening' ? ( - /* Sentence Listening Test Mode - 例句聽力題 */ -
- {/* Error Report Button */} -
- -
- -
- {/* Title in top-left */} -
-

- 例句聽力 -

- - {currentCard.difficultyLevel} - -
- - {/* Instructions Test Action */} -

- 請聽例句並選擇正確的選項: -

- -
- -
- -

- 點擊播放聽例句 -

-
-
- -
- {sentenceOptions.map((sentence, idx) => ( - - ))} -
- - {showResult && ( -
-

- {selectedAnswer === currentCard.example ? '正確!' : '錯誤!'} -

- - {selectedAnswer !== currentCard.example && ( -
-

- 正確答案是:"{currentCard.example}" -

-

- 中文翻譯:{currentCard.exampleTranslation} -

-
- )} -
- )} -
- - {/* Navigation */} -
- - -
-
- ) : mode === 'sentence-reorder' ? ( - /* Sentence Reorder Mode - 例句重組題 */ -
- {/* Error Report Button */} -
- -
- -
- {/* Title in top-left */} -
-

- 例句重組 -

- - {currentCard.difficultyLevel} - -
- - - {/* Example Image */} - {currentCard.exampleImage && ( -
-
- Example illustration setModalImage(currentCard.exampleImage || null)} - /> -
-
- )} - - - - {/* Arranged Sentence Area */} -
-

重組區域:

-
- {arrangedWords.length === 0 ? ( -
- 答案區 -
- ) : ( -
- {arrangedWords.map((word, index) => ( -
handleRemoveFromArranged(word)} - > - {word} - × -
- ))} -
- )} -
-
- - {/* Instructions Test Action */} -

- 點擊下方單字,依序重組成正確的句子: -

- - {/* Shuffled Words */} -
-

可用單字:

-
- {shuffledWords.length === 0 ? ( -
- 所有單字都已使用 -
- ) : ( -
- {shuffledWords.map((word, index) => ( - - ))} -
- )} -
-
- - {/* Control Buttons */} -
- {arrangedWords.length > 0 && ( - - )} - -
- - {/* Result Feedback */} - {reorderResult !== null && ( -
-

- {reorderResult ? '正確!' : '錯誤!'} -

- - {!reorderResult && ( -
-

- 正確答案是:"{currentCard.example}" -

-
- )} - -
-
-

- 中文翻譯:{currentCard.exampleTranslation} -

-
-
-
- )} -
- - {/* Navigation */} -
- - -
-
- ) : null} - - {/* Report Modal */} {showReportModal && (

回報錯誤

-

- 單字:{reportingCard?.word} -

+

單字:{reportingCard?.word}

- - setReportReason(e.target.value)} className="w-full p-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"> @@ -2162,268 +218,13 @@ export default function LearnPage() {
- - + +
)} - - {/* Image Modal */} - {modalImage && ( -
setModalImage(null)} - > -
- 放大圖片 - -
-
- )} - - {/* Task List Modal */} - {showTaskListModal && ( -
-
- {/* Header */} -
-

- 📚 學習任務清單 -

- -
- - {/* Content */} -
- {/* 進度統計 */} -
-
- - 測驗進度: {completedTests} / {totalTests} ({totalTests > 0 ? Math.round((completedTests / totalTests) * 100) : 0}%) - -
- ✅ 已完成: {testItems.filter(item => item.isCompleted).length} - ⏳ 進行中: {testItems.filter(item => item.isCurrent).length} - ⚪ 待完成: {testItems.filter(item => !item.isCompleted && !item.isCurrent).length} -
-
-
-
0 ? (completedTests / totalTests) * 100 : 0}%` }} - >
-
-
- - {/* 任務清單 */} -
- {groupTestItemsByCard(testItems).map((cardGroup, cardIndex) => ( -
- {/* 詞卡標題 */} -
- - 詞卡{cardIndex + 1}: {cardGroup.word} - - - {cardGroup.context} - - - {cardGroup.tests.length}個測驗 - -
- - {/* 測驗項目 */} -
- {cardGroup.tests.map(test => ( -
- {/* 狀態圖標 */} - - {test.isCompleted ? '✅' : test.isCurrent ? '⏳' : '⚪'} - - - {/* 測驗資訊 */} -
-
- {test.order}. {test.testName} -
-
- {test.isCompleted ? '已完成' : - test.isCurrent ? '進行中' : '待完成'} -
-
-
- ))} -
-
- ))} -
- - {testItems.length === 0 && ( -
-
📚
-

還沒有生成任務清單

-
- )} -
-
-
- )} - - {/* Complete Modal */} - {showComplete && ( - router.push('/dashboard')} - /> - )} - - {/* No Due Cards Modal */} - {showNoDueCards && ( -
-
-
📚
-

- 今日學習已完成! -

-

- 目前沒有到期需要複習的詞卡。您可以: -

- -
-
-
💡 建議行動
-
    -
  • • 前往詞卡管理頁面新增詞卡
  • -
  • • 查看學習統計和進度
  • -
  • • 調整學習目標和設定
  • -
-
-
- -
- - -
- - -
-
- )} -
- -
) } \ No newline at end of file diff --git a/frontend/components/learn-backup/learn/tests/FlipMemoryTest.tsx b/frontend/components/learn-backup/learn/tests/FlipMemoryTest.tsx new file mode 100644 index 0000000..3ef6d07 --- /dev/null +++ b/frontend/components/learn-backup/learn/tests/FlipMemoryTest.tsx @@ -0,0 +1,25 @@ +interface FlipMemoryTestProps { + currentCard: any + cardHeight: number + isFlipped: boolean + onFlip: () => void + onReportError: () => void + onNavigate: (direction: string) => void + currentCardIndex: number + totalCards: number + cardContainerRef: any + cardFrontRef: any + cardBackRef: any +} + +export const FlipMemoryTest: React.FC = (props) => { + return ( +
+

FlipMemoryTest 測驗組件

+

詞卡: {props.currentCard?.word}

+ +
+ ) +} \ No newline at end of file diff --git a/frontend/components/learn-backup/learn/tests/SentenceFillTest.tsx b/frontend/components/learn-backup/learn/tests/SentenceFillTest.tsx new file mode 100644 index 0000000..98abdc8 --- /dev/null +++ b/frontend/components/learn-backup/learn/tests/SentenceFillTest.tsx @@ -0,0 +1,36 @@ +interface SentenceFillTestProps { + currentCard: any + fillAnswer: string + showHint: boolean + showResult: boolean + onAnswerChange: (answer: string) => void + onSubmit: () => void + onToggleHint: () => void + onReportError: () => void + onNavigate: (direction: string) => void + currentCardIndex: number + totalCards: number + setModalImage: (image: string | null) => void +} + +export const SentenceFillTest: React.FC = (props) => { + return ( +
+

SentenceFillTest 測驗組件

+

詞卡: {props.currentCard?.word}

+ props.onAnswerChange(e.target.value)} + className="border px-3 py-2 rounded mx-2" + placeholder="輸入答案" + /> + +
+ ) +} \ No newline at end of file diff --git a/frontend/components/learn-backup/learn/tests/SentenceReorderTest.tsx b/frontend/components/learn-backup/learn/tests/SentenceReorderTest.tsx new file mode 100644 index 0000000..56e1b44 --- /dev/null +++ b/frontend/components/learn-backup/learn/tests/SentenceReorderTest.tsx @@ -0,0 +1,69 @@ +interface SentenceReorderTestProps { + currentCard: any + shuffledWords: string[] + arrangedWords: string[] + reorderResult: boolean | null + onWordClick: (word: string) => void + onRemoveFromArranged: (word: string) => void + onCheckAnswer: () => void + onReset: () => void + onReportError: () => void + onNavigate: (direction: string) => void + currentCardIndex: number + totalCards: number + setModalImage: (image: string | null) => void +} + +export const SentenceReorderTest: React.FC = (props) => { + return ( +
+

SentenceReorderTest 測驗組件

+

詞卡: {props.currentCard?.word}

+ +
+

可用詞語:

+
+ {props.shuffledWords.map((word, index) => ( + + ))} +
+
+ +
+

你的句子:

+
+ {props.arrangedWords.map((word, index) => ( + + ))} +
+
+ +
+ + +
+
+ ) +} \ No newline at end of file diff --git a/frontend/components/learn-backup/learn/tests/VocabChoiceTest.tsx b/frontend/components/learn-backup/learn/tests/VocabChoiceTest.tsx new file mode 100644 index 0000000..c8eb81a --- /dev/null +++ b/frontend/components/learn-backup/learn/tests/VocabChoiceTest.tsx @@ -0,0 +1,31 @@ +interface VocabChoiceTestProps { + currentCard: any + quizOptions: string[] + selectedAnswer: string | null + showResult: boolean + onAnswer: (answer: string) => void + onReportError: () => void + onNavigate: (direction: string) => void + currentCardIndex: number + totalCards: number +} + +export const VocabChoiceTest: React.FC = (props) => { + return ( +
+

VocabChoiceTest 測驗組件

+

詞卡: {props.currentCard?.word}

+
+ {props.quizOptions.map((option, index) => ( + + ))} +
+
+ ) +} \ No newline at end of file diff --git a/frontend/components/learn-backup/learn/tests/index.ts b/frontend/components/learn-backup/learn/tests/index.ts new file mode 100644 index 0000000..fd15331 --- /dev/null +++ b/frontend/components/learn-backup/learn/tests/index.ts @@ -0,0 +1,4 @@ +export { FlipMemoryTest } from './FlipMemoryTest' +export { VocabChoiceTest } from './VocabChoiceTest' +export { SentenceFillTest } from './SentenceFillTest' +export { SentenceReorderTest } from './SentenceReorderTest' \ No newline at end of file diff --git a/frontend/components/learn/LoadingStates.tsx b/frontend/components/learn/LoadingStates.tsx new file mode 100644 index 0000000..b92e417 --- /dev/null +++ b/frontend/components/learn/LoadingStates.tsx @@ -0,0 +1,59 @@ +import { useRouter } from 'next/navigation' + +interface LoadingStatesProps { + isLoadingCard?: boolean + isAutoSelecting?: boolean + showNoDueCards?: boolean + onRestart?: () => void +} + +export const LoadingStates: React.FC = ({ + isLoadingCard = false, + isAutoSelecting = false, + showNoDueCards = false, + onRestart +}) => { + const router = useRouter() + + // 載入中狀態 + if (isLoadingCard) { + return ( +
+
+ {isAutoSelecting ? '系統正在選擇最適合的複習方式...' : '載入中...'} +
+
+ ) + } + + // 沒有到期詞卡狀態 + if (showNoDueCards) { + return ( +
+
+
+
📚
+

今日學習已完成!

+

目前沒有到期需要複習的詞卡。

+
+ + +
+
+
+
+ ) + } + + return null +} \ No newline at end of file diff --git a/frontend/components/learn/ProgressTracker.tsx b/frontend/components/learn/ProgressTracker.tsx new file mode 100644 index 0000000..d19b425 --- /dev/null +++ b/frontend/components/learn/ProgressTracker.tsx @@ -0,0 +1,44 @@ +interface ProgressTrackerProps { + completedTests: number + totalTests: number + onShowTaskList: () => void +} + +export const ProgressTracker: React.FC = ({ + completedTests, + totalTests, + onShowTaskList +}) => { + const progressPercentage = totalTests > 0 ? (completedTests / totalTests) * 100 : 0 + + return ( +
+
+ 學習進度 +
+ +
+
+ +
+
+
+
+ ) +} \ No newline at end of file diff --git a/frontend/components/learn/ReviewContainer.tsx b/frontend/components/learn/ReviewContainer.tsx new file mode 100644 index 0000000..4ce7cb6 --- /dev/null +++ b/frontend/components/learn/ReviewContainer.tsx @@ -0,0 +1,284 @@ +import { useRef } from 'react' + +// 複習模式類型 +type ReviewMode = 'flip-memory' | 'vocab-choice' | 'vocab-listening' | 'sentence-listening' | 'sentence-fill' | 'sentence-reorder' | 'sentence-speaking' + +// 擴展的Flashcard接口 +interface ExtendedFlashcard { + id: string + word: string + definition: string + example: string + difficultyLevel?: string + [key: string]: any +} + +interface ReviewContainerProps { + // 當前詞卡和模式 + currentCard: ExtendedFlashcard | null + mode: ReviewMode + + // 答題狀態 + selectedAnswer: string | null + showResult: boolean + fillAnswer: string + showHint: boolean + isFlipped: boolean + + // 題型特定狀態 + quizOptions: string[] + shuffledWords: string[] + arrangedWords: string[] + reorderResult: boolean | null + + // 導航狀態 + currentCardIndex: number + totalCards: number + + // 事件處理器 + onAnswer: (answer: string) => void + onFillSubmit: () => void + onFillAnswerChange: (answer: string) => void + onToggleHint: () => void + onFlip: () => void + onConfidenceLevel: (level: number) => void + onWordClick: (word: string) => void + onRemoveFromArranged: (word: string) => void + onCheckReorderAnswer: () => void + onResetReorder: () => void + onReportError: () => void + onNavigate: (direction: 'previous' | 'next') => void + setModalImage: (image: string | null) => void +} + +export const ReviewContainer: React.FC = ({ + currentCard, + mode, + selectedAnswer, + showResult, + fillAnswer, + showHint, + isFlipped, + quizOptions, + shuffledWords, + arrangedWords, + reorderResult, + currentCardIndex, + totalCards, + onAnswer, + onFillSubmit, + onFillAnswerChange, + onToggleHint, + onFlip, + onConfidenceLevel, + onWordClick, + onRemoveFromArranged, + onCheckReorderAnswer, + onResetReorder, + onReportError, + onNavigate, + setModalImage +}) => { + // Refs for card height calculation + const cardContainerRef = useRef(null) + const cardFrontRef = useRef(null) + const cardBackRef = useRef(null) + + if (!currentCard) { + return ( +
+
載入詞卡中...
+
+ ) + } + + // 渲染不同的測驗類型 + const renderTestComponent = () => { + switch (mode) { + case 'flip-memory': + return ( +
+

翻卡記憶測驗

+
詞卡: {currentCard.word}
+ +
+ {!isFlipped ? ( +
+
{currentCard.word}
+
+ ) : ( +
+
{currentCard.definition}
+
{currentCard.example}
+
+ )} +
+ + + + {isFlipped && ( +
+ +
+ )} +
+ ) + + case 'vocab-choice': + return ( +
+

詞彙選擇測驗

+
選擇正確的單字意思
+
{currentCard.definition}
+ +
+ {quizOptions.map((option, index) => ( + + ))} +
+ + {showResult && ( +
+ +
+ )} +
+ ) + + case 'sentence-fill': + return ( +
+

例句填空測驗

+
填入正確的單字
+ +
+
+ {currentCard.example?.replace(currentCard.word, '___')} +
+
+ +
+ onFillAnswerChange(e.target.value)} + className="border-2 border-gray-300 px-4 py-2 rounded-lg w-48 focus:border-blue-500" + placeholder="輸入答案" + /> +
+ + + + {showResult && ( +
+ +
+ )} +
+ ) + + case 'sentence-reorder': + return ( +
+

例句重組測驗

+
重新排列單字組成正確句子
+ +
+

可用詞語:

+
+ {shuffledWords.map((word, index) => ( + + ))} +
+
+ +
+

你的句子:

+
+ {arrangedWords.map((word, index) => ( + + ))} +
+
+ +
+ + +
+ + {reorderResult !== null && ( +
+
+ {reorderResult ? '✅ 正確!' : '❌ 不正確,請再試試'} +
+ {reorderResult && ( + + )} +
+ )} +
+ ) + + default: + return ( +
+
+ 測驗類型 "{mode}" 尚未實現 +
+ +
+ ) + } + } + + return ( +
+ {renderTestComponent()} +
+ ) +} \ No newline at end of file diff --git a/frontend/components/learn/TaskListModal.tsx b/frontend/components/learn/TaskListModal.tsx new file mode 100644 index 0000000..2a30e85 --- /dev/null +++ b/frontend/components/learn/TaskListModal.tsx @@ -0,0 +1,129 @@ +interface TestItem { + id: string + cardId: string + word: string + testType: string + testName: string + isCompleted: boolean + isCurrent: boolean + order: number +} + +interface TaskListModalProps { + isOpen: boolean + onClose: () => void + testItems: TestItem[] + completedTests: number + totalTests: number +} + +export const TaskListModal: React.FC = ({ + isOpen, + onClose, + testItems, + completedTests, + totalTests +}) => { + if (!isOpen) return null + + const progressPercentage = totalTests > 0 ? Math.round((completedTests / totalTests) * 100) : 0 + const completedCount = testItems.filter(item => item.isCompleted).length + const currentCount = testItems.filter(item => item.isCurrent).length + const pendingCount = testItems.filter(item => !item.isCompleted && !item.isCurrent).length + + return ( +
+
+ {/* Header */} +
+

+ 📚 學習任務清單 +

+ +
+ + {/* Content */} +
+ {/* 進度統計 */} +
+
+ + 測驗進度: {completedTests} / {totalTests} ({progressPercentage}%) + +
+ ✅ 已完成: {completedCount} + ⏳ 進行中: {currentCount} + ⚪ 待完成: {pendingCount} +
+
+
+
+
+
+ + {/* 測驗清單 */} +
+ {testItems.length > 0 ? ( +
+ {testItems.map((item) => ( +
+ {/* 狀態圖標 */} + + {item.isCompleted ? '✅' : item.isCurrent ? '⏳' : '⚪'} + + + {/* 測驗資訊 */} +
+
+ {item.order}. {item.word} - {item.testName} +
+
+ {item.isCompleted ? '已完成' : + item.isCurrent ? '進行中' : '待完成'} +
+
+
+ ))} +
+ ) : ( +
+
📚
+

還沒有生成任務清單

+
+ )} +
+
+ + {/* Footer */} +
+ +
+
+
+ ) +} \ No newline at end of file diff --git a/frontend/hooks/learn/useProgressTracker.ts b/frontend/hooks/learn/useProgressTracker.ts new file mode 100644 index 0000000..f1bdaf0 --- /dev/null +++ b/frontend/hooks/learn/useProgressTracker.ts @@ -0,0 +1,66 @@ +import { useState } from 'react' + +// 分數狀態接口 +interface Score { + correct: number + total: number +} + +// 進度追蹤狀態接口 +interface ProgressTrackerState { + score: Score + showTaskListModal: boolean +} + +// Hook返回接口 +interface UseProgressTrackerReturn extends ProgressTrackerState { + updateScore: (isCorrect: boolean) => void + resetScore: () => void + setShowTaskListModal: (show: boolean) => void + getAccuracyPercentage: () => number + getProgressPercentage: (completed: number, total: number) => number +} + +export const useProgressTracker = (): UseProgressTrackerReturn => { + // 進度追蹤狀態 + const [score, setScore] = useState({ correct: 0, total: 0 }) + const [showTaskListModal, setShowTaskListModal] = useState(false) + + // 更新分數 + const updateScore = (isCorrect: boolean): void => { + setScore(prev => ({ + correct: isCorrect ? prev.correct + 1 : prev.correct, + total: prev.total + 1 + })) + } + + // 重置分數 + const resetScore = (): void => { + setScore({ correct: 0, total: 0 }) + } + + // 獲取準確率百分比 + const getAccuracyPercentage = (): number => { + if (score.total === 0) return 0 + return Math.round((score.correct / score.total) * 100) + } + + // 獲取進度百分比 + const getProgressPercentage = (completed: number, total: number): number => { + if (total === 0) return 0 + return Math.round((completed / total) * 100) + } + + return { + // 狀態 + score, + showTaskListModal, + + // 操作函數 + updateScore, + resetScore, + setShowTaskListModal, + getAccuracyPercentage, + getProgressPercentage + } +} \ No newline at end of file diff --git a/frontend/hooks/learn/useReviewSession.ts b/frontend/hooks/learn/useReviewSession.ts new file mode 100644 index 0000000..3879b7e --- /dev/null +++ b/frontend/hooks/learn/useReviewSession.ts @@ -0,0 +1,156 @@ +import { useState, useEffect } from 'react' +import { flashcardsService, type Flashcard } from '@/lib/services/flashcards' +import { getReviewTypesByCEFR } from '@/lib/utils/cefrUtils' + +// 擴展的Flashcard接口 +interface ExtendedFlashcard extends Omit { + nextReviewDate?: string + currentInterval?: number + isOverdue?: boolean + overdueDays?: number + baseMasteryLevel?: number + lastReviewDate?: string + synonyms?: string[] + exampleImage?: string +} + +// 複習模式類型 +type ReviewMode = 'flip-memory' | 'vocab-choice' | 'vocab-listening' | 'sentence-listening' | 'sentence-fill' | 'sentence-reorder' | 'sentence-speaking' + +// Hook狀態接口 +interface ReviewSessionState { + currentCard: ExtendedFlashcard | null + dueCards: ExtendedFlashcard[] + currentCardIndex: number + isLoadingCard: boolean + mode: ReviewMode + isAutoSelecting: boolean + showNoDueCards: boolean + showComplete: boolean +} + +// Hook返回接口 +interface UseReviewSessionReturn extends ReviewSessionState { + loadDueCards: () => Promise + setCurrentCard: (card: ExtendedFlashcard | null) => void + setCurrentCardIndex: (index: number) => void + setMode: (mode: ReviewMode) => void + setIsAutoSelecting: (selecting: boolean) => void + setShowNoDueCards: (show: boolean) => void + setShowComplete: (show: boolean) => void + nextCard: () => void + previousCard: () => void + restart: () => Promise +} + + +export const useReviewSession = (): UseReviewSessionReturn => { + // 核心複習狀態 + const [currentCard, setCurrentCard] = useState(null) + const [dueCards, setDueCards] = useState([]) + const [currentCardIndex, setCurrentCardIndex] = useState(0) + const [isLoadingCard, setIsLoadingCard] = useState(false) + const [mode, setMode] = useState('flip-memory') + const [isAutoSelecting, setIsAutoSelecting] = useState(true) + const [showNoDueCards, setShowNoDueCards] = useState(false) + const [showComplete, setShowComplete] = useState(false) + + // 載入到期詞卡 + const loadDueCards = async (): Promise => { + try { + setIsLoadingCard(true) + console.log('🔍 開始載入到期詞卡...') + + const apiResult = await flashcardsService.getDueFlashcards(50) + console.log('📡 API回應結果:', apiResult) + + if (apiResult.success && apiResult.data && apiResult.data.length > 0) { + const cardsToUse = apiResult.data + console.log('✅ 載入後端API數據成功:', cardsToUse.length, '張詞卡') + + setDueCards(cardsToUse) + setCurrentCardIndex(0) + setCurrentCard(cardsToUse[0]) + + // 自動選擇複習模式 + const userCEFRLevel = localStorage.getItem('userEnglishLevel') || 'A2' + const wordCEFRLevel = cardsToUse[0].difficultyLevel || 'A2' + const reviewTypes = getReviewTypesByCEFR(userCEFRLevel, wordCEFRLevel) + + if (reviewTypes.length > 0) { + const selectedMode = reviewTypes[0] as ReviewMode + setMode(selectedMode) + } + + setIsAutoSelecting(false) + setShowNoDueCards(false) + setShowComplete(false) + } else { + console.log('❌ 沒有到期詞卡') + setDueCards([]) + setCurrentCard(null) + setShowNoDueCards(true) + setShowComplete(false) + } + } catch (error) { + console.error('💥 載入到期詞卡失敗:', error) + setDueCards([]) + setCurrentCard(null) + setShowNoDueCards(true) + } finally { + setIsLoadingCard(false) + } + } + + // 下一張詞卡 + const nextCard = (): void => { + if (currentCardIndex < dueCards.length - 1) { + const nextIndex = currentCardIndex + 1 + setCurrentCardIndex(nextIndex) + setCurrentCard(dueCards[nextIndex]) + } else { + setShowComplete(true) + } + } + + // 上一張詞卡 + const previousCard = (): void => { + if (currentCardIndex > 0) { + const prevIndex = currentCardIndex - 1 + setCurrentCardIndex(prevIndex) + setCurrentCard(dueCards[prevIndex]) + } + } + + // 重新開始 + const restart = async (): Promise => { + setCurrentCardIndex(0) + setShowComplete(false) + setShowNoDueCards(false) + await loadDueCards() + } + + return { + // 狀態 + currentCard, + dueCards, + currentCardIndex, + isLoadingCard, + mode, + isAutoSelecting, + showNoDueCards, + showComplete, + + // 操作函數 + loadDueCards, + setCurrentCard, + setCurrentCardIndex, + setMode, + setIsAutoSelecting, + setShowNoDueCards, + setShowComplete, + nextCard, + previousCard, + restart + } +} \ No newline at end of file diff --git a/frontend/hooks/learn/useTestAnswering.ts b/frontend/hooks/learn/useTestAnswering.ts new file mode 100644 index 0000000..e5a93be --- /dev/null +++ b/frontend/hooks/learn/useTestAnswering.ts @@ -0,0 +1,159 @@ +import { useState } from 'react' + +// 答題狀態接口 +interface TestAnsweringState { + selectedAnswer: string | null + showResult: boolean + fillAnswer: string + showHint: boolean + isFlipped: boolean + quizOptions: string[] + sentenceOptions: string[] + shuffledWords: string[] + arrangedWords: string[] + reorderResult: boolean | null +} + +// Hook返回接口 +interface UseTestAnsweringReturn extends TestAnsweringState { + // 基本狀態控制 + setSelectedAnswer: (answer: string | null) => void + setShowResult: (show: boolean) => void + setFillAnswer: (answer: string) => void + setShowHint: (show: boolean) => void + setIsFlipped: (flipped: boolean) => void + + // 題型選項管理 + setQuizOptions: (options: string[]) => void + setSentenceOptions: (options: string[]) => void + + // 重組題狀態管理 + setShuffledWords: (words: string[]) => void + setArrangedWords: (words: string[]) => void + setReorderResult: (result: boolean | null) => void + + // 重組題操作 + addWordToArranged: (word: string) => void + removeWordFromArranged: (word: string) => void + resetReorderTest: (originalSentence: string) => void + + // 重置所有狀態 + resetAllAnsweringStates: () => void + + // 答題檢查 + checkVocabChoice: (correctAnswer: string) => boolean + checkSentenceFill: (correctAnswer: string) => boolean + checkSentenceReorder: (correctSentence: string) => boolean +} + +export const useTestAnswering = (): UseTestAnsweringReturn => { + // 基本答題狀態 + const [selectedAnswer, setSelectedAnswer] = useState(null) + const [showResult, setShowResult] = useState(false) + const [fillAnswer, setFillAnswer] = useState('') + const [showHint, setShowHint] = useState(false) + const [isFlipped, setIsFlipped] = useState(false) + + // 題型選項狀態 + const [quizOptions, setQuizOptions] = useState([]) + const [sentenceOptions, setSentenceOptions] = useState([]) + + // 例句重組狀態 + const [shuffledWords, setShuffledWords] = useState([]) + const [arrangedWords, setArrangedWords] = useState([]) + const [reorderResult, setReorderResult] = useState(null) + + // 重組題操作:添加詞到排列中 + const addWordToArranged = (word: string): void => { + setShuffledWords(prev => prev.filter(w => w !== word)) + setArrangedWords(prev => [...prev, word]) + setReorderResult(null) + } + + // 重組題操作:從排列中移除詞 + const removeWordFromArranged = (word: string): void => { + setArrangedWords(prev => prev.filter(w => w !== word)) + setShuffledWords(prev => [...prev, word]) + setReorderResult(null) + } + + // 重組題操作:重置測驗 + const resetReorderTest = (originalSentence: string): void => { + const words = originalSentence.split(/\s+/).filter(word => word.length > 0) + const shuffled = [...words].sort(() => Math.random() - 0.5) + setShuffledWords(shuffled) + setArrangedWords([]) + setReorderResult(null) + } + + // 重置所有答題狀態 + const resetAllAnsweringStates = (): void => { + setSelectedAnswer(null) + setShowResult(false) + setFillAnswer('') + setShowHint(false) + setIsFlipped(false) + setQuizOptions([]) + setSentenceOptions([]) + setShuffledWords([]) + setArrangedWords([]) + setReorderResult(null) + } + + // 檢查詞彙選擇題答案 + const checkVocabChoice = (correctAnswer: string): boolean => { + return selectedAnswer === correctAnswer + } + + // 檢查例句填空題答案 + const checkSentenceFill = (correctAnswer: string): boolean => { + return fillAnswer.toLowerCase().trim() === correctAnswer.toLowerCase() + } + + // 檢查例句重組題答案 + const checkSentenceReorder = (correctSentence: string): boolean => { + const userSentence = arrangedWords.join(' ') + return userSentence.toLowerCase().trim() === correctSentence.toLowerCase().trim() + } + + return { + // 狀態 + selectedAnswer, + showResult, + fillAnswer, + showHint, + isFlipped, + quizOptions, + sentenceOptions, + shuffledWords, + arrangedWords, + reorderResult, + + // 基本狀態控制 + setSelectedAnswer, + setShowResult, + setFillAnswer, + setShowHint, + setIsFlipped, + + // 題型選項管理 + setQuizOptions, + setSentenceOptions, + + // 重組題狀態管理 + setShuffledWords, + setArrangedWords, + setReorderResult, + + // 重組題操作 + addWordToArranged, + removeWordFromArranged, + resetReorderTest, + + // 工具函數 + resetAllAnsweringStates, + checkVocabChoice, + checkSentenceFill, + checkSentenceReorder + } +} \ No newline at end of file diff --git a/frontend/hooks/learn/useTestQueue.ts b/frontend/hooks/learn/useTestQueue.ts new file mode 100644 index 0000000..46b0edf --- /dev/null +++ b/frontend/hooks/learn/useTestQueue.ts @@ -0,0 +1,254 @@ +import { useState } from 'react' +import { flashcardsService } from '@/lib/services/flashcards' +import { getReviewTypesByCEFR, getModeLabel } from '@/lib/utils/cefrUtils' + +// 測驗項目接口 +interface TestItem { + id: string + cardId: string + word: string + testType: string + testName: string + isCompleted: boolean + isCurrent: boolean + order: number +} + +// 測驗結果接口 +interface TestResult { + testType: string + isCorrect: boolean + userAnswer?: string + confidenceLevel?: number + responseTimeMs: number + completedAt: Date +} + +// Hook狀態接口 +interface TestQueueState { + totalTests: number + completedTests: number + testItems: TestItem[] + currentTestItemIndex: number +} + +// Hook返回接口 +interface UseTestQueueReturn extends TestQueueState { + initializeTestQueue: (cards: any[], completedTests: any[]) => void + recordTestResult: (isCorrect: boolean, userAnswer?: string, confidenceLevel?: number) => Promise + loadNextUncompletedTest: () => void + skipCurrentTest: () => void + resetTestQueue: () => void + getCompletedTestsForCards: (cardIds: string[]) => Promise +} + + +export const useTestQueue = (): UseTestQueueReturn => { + // 測驗隊列狀態 + const [totalTests, setTotalTests] = useState(0) + const [completedTests, setCompletedTests] = useState(0) + const [testItems, setTestItems] = useState([]) + const [currentTestItemIndex, setCurrentTestItemIndex] = useState(0) + + // 初始化測驗隊列 + const initializeTestQueue = (cards: any[], completedTests: any[] = []): void => { + const userCEFRLevel = localStorage.getItem('userEnglishLevel') || 'A2' + let remainingTestItems: TestItem[] = [] + let order = 1 + + cards.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, + testName: getModeLabel(testType), + isCompleted: false, + isCurrent: false, + order + }) + order++ + }) + }) + + if (remainingTestItems.length === 0) { + console.log('🎉 所有測驗都已完成!') + return + } + + console.log('📝 剩餘測驗項目:', remainingTestItems.length, '個') + + setTotalTests(remainingTestItems.length) + setTestItems(remainingTestItems) + setCurrentTestItemIndex(0) + setCompletedTests(0) + + // 標記第一個測驗為當前 + setTestItems(prev => + prev.map((item, index) => + index === 0 ? { ...item, isCurrent: true } : item + ) + ) + } + + // 獲取已完成的測驗 + const getCompletedTestsForCards = async (cardIds: string[]): Promise => { + try { + const result = await flashcardsService.getCompletedTests(cardIds) + if (result.success && result.data) { + console.log('📊 已完成測驗:', result.data.length, '個') + return result.data + } + } catch (error) { + console.error('💥 查詢已完成測驗異常:', error) + } + return [] + } + + // 記錄測驗結果 + const recordTestResult = async ( + isCorrect: boolean, + userAnswer?: string, + confidenceLevel?: number + ): Promise => { + const token = localStorage.getItem('auth_token') + if (!token) { + console.error('❌ 未找到認證token,請重新登入') + return + } + + const currentTestItem = testItems[currentTestItemIndex] + if (!currentTestItem) return + + try { + console.log('🔄 開始記錄測驗結果到資料庫...', { + flashcardId: currentTestItem.cardId, + testType: currentTestItem.testType, + word: currentTestItem.word, + isCorrect, + hasToken: !!token + }) + + const result = await flashcardsService.recordTestCompletion({ + flashcardId: currentTestItem.cardId, + testType: currentTestItem.testType, + isCorrect, + userAnswer, + confidenceLevel, + responseTimeMs: 2000 + }) + + if (result.success) { + console.log('✅ 測驗結果已記錄到資料庫:', currentTestItem.testType, 'for', currentTestItem.word) + + // 更新本地狀態 + setCompletedTests(prev => prev + 1) + setTestItems(prev => + prev.map((item, index) => + index === currentTestItemIndex + ? { ...item, isCompleted: true, isCurrent: false } + : item + ) + ) + setCurrentTestItemIndex(prev => prev + 1) + + // 延遲載入下一個測驗 + setTimeout(() => { + loadNextUncompletedTest() + }, 1500) + } else { + console.error('❌ 記錄測驗結果失敗:', result.error) + handleTestError() + } + } catch (error) { + console.error('💥 記錄測驗結果異常:', error) + handleTestError() + } + } + + // 處理測驗錯誤 + const handleTestError = (): void => { + setCompletedTests(prev => prev + 1) + setCurrentTestItemIndex(prev => prev + 1) + setTimeout(() => { + loadNextUncompletedTest() + }, 1500) + } + + // 載入下一個未完成測驗 + const loadNextUncompletedTest = (): void => { + if (currentTestItemIndex + 1 < testItems.length) { + const nextIndex = currentTestItemIndex + 1 + setTestItems(prev => + prev.map((item, index) => + index === nextIndex + ? { ...item, isCurrent: true } + : { ...item, isCurrent: false } + ) + ) + console.log(`🔄 載入下一個測驗: ${testItems[nextIndex]?.word} - ${testItems[nextIndex]?.testType}`) + } else { + console.log('🎉 所有測驗完成!') + } + } + + // 跳過當前測驗 + const skipCurrentTest = (): void => { + // 將當前測驗移到隊列最後 + const currentTest = testItems[currentTestItemIndex] + if (!currentTest) return + + setTestItems(prev => { + const newItems = [...prev] + // 移除當前項目 + newItems.splice(currentTestItemIndex, 1) + // 添加到最後 + newItems.push({ ...currentTest, isCurrent: false }) + // 標記新的當前項目 + if (newItems[currentTestItemIndex]) { + newItems[currentTestItemIndex].isCurrent = true + } + return newItems + }) + + console.log(`⏭️ 跳過測驗: ${currentTest.word} - ${currentTest.testType}`) + } + + // 重置測驗隊列 + const resetTestQueue = (): void => { + setTotalTests(0) + setCompletedTests(0) + setTestItems([]) + setCurrentTestItemIndex(0) + } + + return { + // 狀態 + totalTests, + completedTests, + testItems, + currentTestItemIndex, + + // 操作函數 + initializeTestQueue, + recordTestResult, + loadNextUncompletedTest, + skipCurrentTest, + resetTestQueue, + getCompletedTestsForCards + } +} \ No newline at end of file diff --git a/frontend/lib/utils/cefrUtils.ts b/frontend/lib/utils/cefrUtils.ts new file mode 100644 index 0000000..1b33f7f --- /dev/null +++ b/frontend/lib/utils/cefrUtils.ts @@ -0,0 +1,38 @@ +// CEFR等級映射 +export const getCEFRToLevel = (cefr: string): number => { + const mapping: { [key: string]: number } = { + 'A1': 20, 'A2': 35, 'B1': 50, 'B2': 65, 'C1': 80, 'C2': 95 + } + return mapping[cefr] || 50 +} + +// 根據CEFR等級獲取複習類型 +export const getReviewTypesByCEFR = (userCEFR: string, wordCEFR: string): string[] => { + const userLevel = getCEFRToLevel(userCEFR) + const wordLevel = getCEFRToLevel(wordCEFR) + const difficulty = wordLevel - userLevel + + if (userCEFR === 'A1') { + return ['flip-memory', 'vocab-choice'] + } else if (difficulty < -10) { + return ['sentence-reorder', 'sentence-fill'] + } else if (difficulty >= -10 && difficulty <= 10) { + return ['sentence-fill', 'sentence-reorder'] + } else { + return ['flip-memory', 'vocab-choice'] + } +} + +// 模式標籤映射 +export const getModeLabel = (mode: string): string => { + const labels: { [key: string]: string } = { + 'flip-memory': '翻卡記憶', + 'vocab-choice': '詞彙選擇', + 'sentence-fill': '例句填空', + 'sentence-reorder': '例句重組', + 'vocab-listening': '詞彙聽力', + 'sentence-listening': '例句聽力', + 'sentence-speaking': '例句口說' + } + return labels[mode] || mode +} \ No newline at end of file