feat: 實現純CEFR字符串智能複習系統和完整四情境對照表
- 將雙欄位架構改為純CEFR字符串架構,消除資料冗余 - 後端API改為接收CEFR字符串,使用即時轉換進行計算 - 前端完全使用CEFR等級進行智能選擇和顯示 - 新增完整四情境對照表,突出顯示當前情境和建議複習方式 - 優化沒有到期詞卡時的用戶體驗,提供專用提示頁面 - 修復例句重組結果閃爍重置問題 - 修復AudioPlayer在p標籤內的HTML結構錯誤 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
ff4c64f1a3
commit
52ae910276
|
|
@ -54,6 +54,7 @@ export default function LearnPage() {
|
|||
const [reportReason, setReportReason] = useState('')
|
||||
const [reportingCard, setReportingCard] = useState<any>(null)
|
||||
const [showComplete, setShowComplete] = useState(false)
|
||||
const [showNoDueCards, setShowNoDueCards] = useState(false)
|
||||
const [cardHeight, setCardHeight] = useState<number>(400)
|
||||
|
||||
// 題型特定狀態
|
||||
|
|
@ -112,170 +113,38 @@ export default function LearnPage() {
|
|||
try {
|
||||
setIsLoadingCard(true)
|
||||
|
||||
// Mock data 展示四情境自動適配效果
|
||||
const mockDueCards: ExtendedFlashcard[] = [
|
||||
// 情境1: A1學習者 (userLevel ≤ 20) → 基礎3題型
|
||||
{
|
||||
id: '1',
|
||||
word: 'cat',
|
||||
partOfSpeech: 'noun',
|
||||
pronunciation: '/kæt/',
|
||||
translation: '貓',
|
||||
definition: 'A small animal that people keep as a pet',
|
||||
example: 'The cat is sleeping on the sofa.',
|
||||
exampleTranslation: '這隻貓正在沙發上睡覺。',
|
||||
masteryLevel: 30,
|
||||
timesReviewed: 1,
|
||||
isFavorite: false,
|
||||
nextReviewDate: new Date().toISOString().split('T')[0],
|
||||
difficultyLevel: 'A1',
|
||||
createdAt: new Date().toISOString(),
|
||||
// A1學習者情境
|
||||
userLevel: 15, // A1學習者
|
||||
wordLevel: 20, // 基礎詞彙
|
||||
baseMasteryLevel: 35,
|
||||
lastReviewDate: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
exampleImages: [],
|
||||
hasExampleImage: false,
|
||||
synonyms: ['kitty', 'feline'],
|
||||
difficulty: 'A1',
|
||||
exampleImage: '' // 移除不存在的圖片
|
||||
},
|
||||
// 完全使用後端API數據
|
||||
const apiResult = await flashcardsService.getDueFlashcards(50);
|
||||
|
||||
// 情境2: 簡單詞彙 (學習者程度 > 詞彙程度) → 應用2題型
|
||||
{
|
||||
id: '2',
|
||||
word: 'happy',
|
||||
partOfSpeech: 'adjective',
|
||||
pronunciation: '/ˈhæpi/',
|
||||
translation: '快樂的',
|
||||
definition: 'Feeling pleasure and contentment',
|
||||
example: 'She looks very happy today because of the good news.',
|
||||
exampleTranslation: '她今天看起來很快樂,因為有好消息。',
|
||||
masteryLevel: 85,
|
||||
timesReviewed: 5,
|
||||
isFavorite: true,
|
||||
nextReviewDate: new Date().toISOString().split('T')[0],
|
||||
difficultyLevel: 'A2',
|
||||
createdAt: new Date().toISOString(),
|
||||
// 簡單詞彙情境 (程度 > 難度)
|
||||
userLevel: 70, // 中級學習者
|
||||
wordLevel: 35, // 簡單詞彙 (difficulty = -35)
|
||||
baseMasteryLevel: 90,
|
||||
lastReviewDate: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
exampleImages: [],
|
||||
hasExampleImage: true,
|
||||
primaryImageUrl: '',
|
||||
synonyms: ['joyful', 'glad', 'cheerful'],
|
||||
difficulty: 'A2',
|
||||
exampleImage: ''
|
||||
},
|
||||
if (apiResult.success && apiResult.data && apiResult.data.length > 0) {
|
||||
const cardsToUse = apiResult.data;
|
||||
console.log('載入後端API數據:', cardsToUse.length, '張詞卡');
|
||||
|
||||
// 情境3: 適中詞彙 (程度 ≈ 難度) → 全方位3題型
|
||||
{
|
||||
id: '3',
|
||||
word: 'determine',
|
||||
partOfSpeech: 'verb',
|
||||
pronunciation: '/dɪˈtɜːrmɪn/',
|
||||
translation: '決定、確定',
|
||||
definition: 'To decide or establish exactly what something is',
|
||||
example: 'We need to determine the best solution for this problem.',
|
||||
exampleTranslation: '我們需要確定這個問題的最佳解決方案。',
|
||||
masteryLevel: 55,
|
||||
timesReviewed: 3,
|
||||
isFavorite: false,
|
||||
nextReviewDate: new Date().toISOString().split('T')[0],
|
||||
difficultyLevel: 'B1',
|
||||
createdAt: new Date().toISOString(),
|
||||
// 適中詞彙情境 (程度 ≈ 難度)
|
||||
userLevel: 60, // 中級學習者
|
||||
wordLevel: 65, // 適中詞彙 (difficulty = +5)
|
||||
baseMasteryLevel: 65,
|
||||
lastReviewDate: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
exampleImages: [],
|
||||
hasExampleImage: true,
|
||||
primaryImageUrl: '',
|
||||
synonyms: ['decide', 'establish', 'figure out'],
|
||||
difficulty: 'B1',
|
||||
exampleImage: ''
|
||||
},
|
||||
setDueCards(cardsToUse);
|
||||
|
||||
// 情境4: 困難詞彙 (程度 < 難度) → 基礎2題型
|
||||
{
|
||||
id: '4',
|
||||
word: 'sophisticated',
|
||||
partOfSpeech: 'adjective',
|
||||
pronunciation: '/səˈfɪstɪkeɪtɪd/',
|
||||
translation: '精密的、複雜的',
|
||||
definition: 'Having advanced knowledge, experience, or understanding',
|
||||
example: 'The new software uses sophisticated algorithms to analyze data.',
|
||||
exampleTranslation: '這個新軟體使用精密的算法來分析數據。',
|
||||
masteryLevel: 25,
|
||||
timesReviewed: 1,
|
||||
isFavorite: false,
|
||||
nextReviewDate: new Date().toISOString().split('T')[0],
|
||||
difficultyLevel: 'C1',
|
||||
createdAt: new Date().toISOString(),
|
||||
// 困難詞彙情境 (程度 < 難度)
|
||||
userLevel: 50, // 中級學習者
|
||||
wordLevel: 85, // 困難詞彙 (difficulty = +35)
|
||||
baseMasteryLevel: 30,
|
||||
lastReviewDate: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
exampleImages: [],
|
||||
hasExampleImage: true,
|
||||
primaryImageUrl: '',
|
||||
synonyms: ['advanced', 'complex', 'refined'],
|
||||
difficulty: 'C1',
|
||||
exampleImage: ''
|
||||
},
|
||||
|
||||
// 額外情境: A1學習者遇到困難詞彙 → 自動保護
|
||||
{
|
||||
id: '5',
|
||||
word: 'dog',
|
||||
partOfSpeech: 'noun',
|
||||
pronunciation: '/dɔːg/',
|
||||
translation: '狗',
|
||||
definition: 'A four-legged animal that people keep as a pet',
|
||||
example: 'My dog likes to play in the park.',
|
||||
exampleTranslation: '我的狗喜歡在公園裡玩。',
|
||||
masteryLevel: 20,
|
||||
timesReviewed: 0,
|
||||
isFavorite: false,
|
||||
nextReviewDate: new Date().toISOString().split('T')[0],
|
||||
difficultyLevel: 'A1',
|
||||
createdAt: new Date().toISOString(),
|
||||
// A1學習者保護測試 (即使是簡單詞彙也用基礎題型)
|
||||
userLevel: 12, // A1初學者
|
||||
wordLevel: 15, // 簡單詞彙,但A1會被保護
|
||||
baseMasteryLevel: 25,
|
||||
lastReviewDate: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
exampleImages: [],
|
||||
hasExampleImage: false,
|
||||
synonyms: ['puppy', 'hound'],
|
||||
difficulty: 'A1',
|
||||
exampleImage: ''
|
||||
}
|
||||
];
|
||||
|
||||
setDueCards(mockDueCards);
|
||||
|
||||
// 直接設置第一張卡片,避免循環依賴
|
||||
if (mockDueCards.length > 0) {
|
||||
const firstCard = mockDueCards[0];
|
||||
// 設置第一張卡片
|
||||
const firstCard = cardsToUse[0];
|
||||
setCurrentCard(firstCard);
|
||||
setCurrentCardIndex(0);
|
||||
|
||||
// 系統自動選擇模式
|
||||
const selectedMode = await selectOptimalReviewMode(firstCard);
|
||||
setMode(selectedMode);
|
||||
setIsAutoSelecting(false); // 確保設置為false
|
||||
setIsAutoSelecting(false);
|
||||
|
||||
console.log(`初始載入: ${firstCard.word}, 選擇模式: ${selectedMode}`);
|
||||
} else {
|
||||
// 沒有到期詞卡
|
||||
console.log('沒有到期的詞卡');
|
||||
setDueCards([]);
|
||||
setCurrentCard(null);
|
||||
setShowNoDueCards(true);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('載入到期詞卡失敗:', error);
|
||||
setDueCards([]);
|
||||
setCurrentCard(null);
|
||||
} finally {
|
||||
setIsLoadingCard(false);
|
||||
}
|
||||
|
|
@ -320,13 +189,43 @@ export default function LearnPage() {
|
|||
|
||||
// 系統自動選擇最適合的複習模式
|
||||
const selectOptimalReviewMode = async (card: ExtendedFlashcard): Promise<typeof mode> => {
|
||||
// 暫時使用前端邏輯,後續整合後端API
|
||||
const userLevel = card.userLevel || 50;
|
||||
const wordLevel = card.wordLevel || 50;
|
||||
try {
|
||||
// 使用CEFR字符串進行智能選擇
|
||||
const userCEFRLevel = localStorage.getItem('userEnglishLevel') || 'A2';
|
||||
const wordCEFRLevel = card.difficultyLevel || 'A2';
|
||||
|
||||
const availableModes = getReviewTypesByDifficulty(userLevel, wordLevel);
|
||||
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',
|
||||
|
|
@ -337,11 +236,121 @@ export default function LearnPage() {
|
|||
'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 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 resetAllStates = () => {
|
||||
setIsFlipped(false);
|
||||
|
|
@ -431,9 +440,12 @@ export default function LearnPage() {
|
|||
const shuffled = [...words].sort(() => Math.random() - 0.5)
|
||||
setShuffledWords(shuffled)
|
||||
setArrangedWords([])
|
||||
// 只在卡片或模式切換時重置結果,不在其他狀態變化時重置
|
||||
if (reorderResult !== null) {
|
||||
setReorderResult(null)
|
||||
}
|
||||
}, [currentCard, mode])
|
||||
}
|
||||
}, [currentCard, mode]) // 移除reorderResult依賴,避免循環重置
|
||||
|
||||
// Sentence reorder handlers
|
||||
const handleWordClick = (word: string) => {
|
||||
|
|
@ -525,15 +537,19 @@ export default function LearnPage() {
|
|||
});
|
||||
|
||||
if (result.success && result.data) {
|
||||
// 更新卡片的熟悉度等資訊
|
||||
console.log('復習結果提交成功:', result.data);
|
||||
// 更新卡片的熟悉度等資訊,但不觸發卡片重新載入
|
||||
setCurrentCard(prev => prev ? {
|
||||
...prev,
|
||||
masteryLevel: result.data!.masteryLevel,
|
||||
nextReviewDate: result.data!.nextReviewDate
|
||||
} : null);
|
||||
} else {
|
||||
console.log('復習結果提交失敗,繼續運行');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('提交復習結果失敗:', error);
|
||||
// 不中斷流程,允許用戶繼續學習
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -612,11 +628,12 @@ export default function LearnPage() {
|
|||
const handleRestart = async () => {
|
||||
setScore({ correct: 0, total: 0 })
|
||||
setShowComplete(false)
|
||||
setShowNoDueCards(false)
|
||||
await loadDueCards(); // 重新載入到期詞卡
|
||||
}
|
||||
|
||||
// Show loading screen until mounted or while loading cards
|
||||
if (!mounted || isLoadingCard || !currentCard) {
|
||||
if (!mounted || isLoadingCard) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center">
|
||||
<div className="text-gray-500 text-lg">
|
||||
|
|
@ -626,6 +643,71 @@ export default function LearnPage() {
|
|||
)
|
||||
}
|
||||
|
||||
// Show no due cards screen
|
||||
if (showNoDueCards) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
|
||||
<Navigation
|
||||
showExitLearning={true}
|
||||
onExitLearning={() => router.push('/dashboard')}
|
||||
/>
|
||||
<div className="max-w-4xl mx-auto px-4 py-8 flex items-center justify-center min-h-[calc(100vh-80px)]">
|
||||
<div className="bg-white rounded-xl p-8 max-w-md w-full text-center shadow-lg">
|
||||
<div className="text-6xl mb-4">📚</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
今日學習已完成!
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
目前沒有到期需要複習的詞卡。您可以:
|
||||
</p>
|
||||
|
||||
<div className="space-y-3 mb-6">
|
||||
<div className="bg-blue-50 rounded-lg p-3 text-left">
|
||||
<div className="font-medium text-blue-900 mb-1">💡 建議行動</div>
|
||||
<ul className="text-blue-800 text-sm space-y-1">
|
||||
<li>• 前往詞卡管理頁面新增詞卡</li>
|
||||
<li>• 查看學習統計和進度</li>
|
||||
<li>• 調整學習目標和設定</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => router.push('/flashcards')}
|
||||
className="flex-1 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors font-medium"
|
||||
>
|
||||
管理詞卡
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push('/dashboard')}
|
||||
className="flex-1 py-3 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors font-medium"
|
||||
>
|
||||
回到首頁
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleRestart}
|
||||
className="w-full mt-3 py-2 text-blue-600 hover:text-blue-800 transition-colors text-sm"
|
||||
>
|
||||
🔄 重新檢查到期詞卡
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show current card interface
|
||||
if (!currentCard) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center">
|
||||
<div className="text-gray-500 text-lg">載入詞卡中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
|
||||
{/* Navigation */}
|
||||
|
|
@ -672,11 +754,13 @@ export default function LearnPage() {
|
|||
<div className="text-blue-700 font-medium">當前情境</div>
|
||||
<div className="text-gray-600">
|
||||
{currentCard && (() => {
|
||||
const userLevel = currentCard.userLevel || 50;
|
||||
const wordLevel = currentCard.wordLevel || 50;
|
||||
const userCEFRLevel = localStorage.getItem('userEnglishLevel') || 'A2';
|
||||
const wordCEFRLevel = currentCard.difficultyLevel || 'A2';
|
||||
const userLevel = getCEFRToLevel(userCEFRLevel);
|
||||
const wordLevel = getCEFRToLevel(wordCEFRLevel);
|
||||
const difficulty = wordLevel - userLevel;
|
||||
|
||||
if (userLevel <= 20) return 'A1學習者';
|
||||
if (userCEFRLevel === 'A1') return 'A1學習者';
|
||||
if (difficulty < -10) return '簡單詞彙';
|
||||
if (difficulty >= -10 && difficulty <= 10) return '適中詞彙';
|
||||
return '困難詞彙';
|
||||
|
|
@ -684,29 +768,110 @@ export default function LearnPage() {
|
|||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded p-3">
|
||||
<div className="text-blue-700 font-medium">學習者程度</div>
|
||||
<div className="text-gray-600">{currentCard?.userLevel || '--'}</div>
|
||||
<div className="text-blue-700 font-medium">學習者等級</div>
|
||||
<div className="text-gray-600">{localStorage.getItem('userEnglishLevel') || 'A2'}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded p-3">
|
||||
<div className="text-blue-700 font-medium">詞彙難度</div>
|
||||
<div className="text-gray-600">{currentCard?.wordLevel || '--'}</div>
|
||||
<div className="text-blue-700 font-medium">詞彙等級</div>
|
||||
<div className="text-gray-600">{currentCard?.difficultyLevel || 'A2'}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded p-3">
|
||||
<div className="text-blue-700 font-medium">難度差異</div>
|
||||
<div className="text-blue-700 font-medium">等級差異</div>
|
||||
<div className="text-gray-600">
|
||||
{currentCard ? (currentCard.wordLevel || 50) - (currentCard.userLevel || 50) : '--'}
|
||||
{currentCard ? (() => {
|
||||
const userCEFR = localStorage.getItem('userEnglishLevel') || 'A2';
|
||||
const wordCEFR = currentCard.difficultyLevel || 'A2';
|
||||
const diff = getCEFRToLevel(wordCEFR) - getCEFRToLevel(userCEFR);
|
||||
return diff > 0 ? `+${diff}` : diff.toString();
|
||||
})() : '--'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg p-3">
|
||||
<div className="text-blue-700 font-medium mb-2">📚 演示說明</div>
|
||||
<div className="text-gray-600 text-xs">
|
||||
• 點擊「下一張」可看到不同情境下系統自動選擇不同的複習題型<br/>
|
||||
• 卡片1-2: A1學習者 → 只會出現基礎題型 (翻卡、選擇、聽力)<br/>
|
||||
• 卡片3: 簡單詞彙 → 應用題型 (填空、重組)<br/>
|
||||
• 卡片4: 適中詞彙 → 全方位題型 (填空、重組、口說)<br/>
|
||||
• 卡片5: 困難詞彙 → 基礎題型 (翻卡、選擇)
|
||||
{/* 當前選擇突出顯示 */}
|
||||
<div className="bg-gradient-to-r from-blue-50 to-blue-100 rounded-lg p-3 mb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
{currentCard && (() => {
|
||||
const userCEFR = localStorage.getItem('userEnglishLevel') || 'A2';
|
||||
const wordCEFR = currentCard.difficultyLevel || 'A2';
|
||||
const context = getCurrentContext(userCEFR, wordCEFR);
|
||||
const contextData = generateContextTable(userCEFR, wordCEFR).find(c => c.isCurrent);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<span className="text-blue-800 font-medium">
|
||||
當前情境: {contextData?.icon} {context}
|
||||
</span>
|
||||
<div className="text-blue-600 text-xs mt-1">
|
||||
可用題型: {contextData?.reviewTypes.join(' | ')}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div className="text-blue-800 text-right">
|
||||
<div className="text-xs">系統已選擇</div>
|
||||
<div className="font-medium flex items-center gap-1">
|
||||
<span>{getModeIcon(mode)}</span>
|
||||
<span>{getModeLabel(mode)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 完整四情境對照表 */}
|
||||
<div className="bg-white rounded-lg p-4">
|
||||
<div className="text-blue-700 font-medium mb-3">📚 智能複習四情境對照表</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="text-left p-2 font-medium text-gray-700">情境類型</th>
|
||||
<th className="text-left p-2 font-medium text-gray-700">建議複習方式</th>
|
||||
<th className="text-left p-2 font-medium text-gray-700">學習目的</th>
|
||||
<th className="text-left p-2 font-medium text-gray-700">判斷條件</th>
|
||||
<th className="text-center p-2 font-medium text-gray-700">狀態</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{currentCard && (() => {
|
||||
const userCEFR = localStorage.getItem('userEnglishLevel') || 'A2';
|
||||
const wordCEFR = currentCard.difficultyLevel || 'A2';
|
||||
const tableData = generateContextTable(userCEFR, wordCEFR);
|
||||
|
||||
return tableData.map((row, index) => (
|
||||
<tr key={index} className={`border-b border-gray-100 ${row.isCurrent ? 'bg-blue-50 ring-1 ring-blue-200' : ''}`}>
|
||||
<td className="p-2">
|
||||
<span className="flex items-center gap-1 font-medium">
|
||||
{row.icon} {row.type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{row.reviewTypes.map((type, idx) => (
|
||||
<span key={idx} className="text-xs whitespace-nowrap">{type}</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-2 text-gray-600">{row.purpose}</td>
|
||||
<td className="p-2 text-gray-500 text-xs">{row.condition}</td>
|
||||
<td className="p-2 text-center">
|
||||
{row.isCurrent && <span className="text-blue-600 font-medium">← 目前</span>}
|
||||
</td>
|
||||
</tr>
|
||||
));
|
||||
})()}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 p-2 bg-gray-50 rounded text-xs text-gray-600">
|
||||
<div className="font-medium mb-1">🧠 智能適配說明:</div>
|
||||
<div>系統根據您的CEFR等級和詞彙CEFR等級自動判斷學習情境,並智能選擇最適合的複習題型。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -726,8 +891,8 @@ export default function LearnPage() {
|
|||
{/* System Auto-Selected Review Type Indicator */}
|
||||
<ReviewTypeIndicator
|
||||
currentMode={mode}
|
||||
userLevel={currentCard?.userLevel}
|
||||
wordLevel={currentCard?.wordLevel}
|
||||
userCEFRLevel={localStorage.getItem('userEnglishLevel') || 'A2'}
|
||||
wordCEFRLevel={currentCard?.difficultyLevel}
|
||||
/>
|
||||
|
||||
{mode === 'flip-memory' ? (
|
||||
|
|
@ -1220,9 +1385,10 @@ export default function LearnPage() {
|
|||
<p className="text-gray-700 text-left">
|
||||
正確答案是:<strong className="text-lg">{currentCard.word}</strong>
|
||||
</p>
|
||||
<p className="text-gray-600 text-left mt-1">
|
||||
發音:{currentCard.pronunciation}
|
||||
</p>
|
||||
<div className="text-gray-600 text-left mt-1 flex items-center gap-2">
|
||||
<span>發音:{currentCard.pronunciation}</span>
|
||||
<AudioPlayer text={currentCard.word} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1669,6 +1835,54 @@ export default function LearnPage() {
|
|||
onBackToDashboard={() => router.push('/dashboard')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* No Due Cards Modal */}
|
||||
{showNoDueCards && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl p-8 max-w-md w-full mx-4 text-center">
|
||||
<div className="text-6xl mb-4">📚</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
今日學習已完成!
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
目前沒有到期需要複習的詞卡。您可以:
|
||||
</p>
|
||||
|
||||
<div className="space-y-3 mb-6">
|
||||
<div className="bg-blue-50 rounded-lg p-3 text-left">
|
||||
<div className="font-medium text-blue-900 mb-1">💡 建議行動</div>
|
||||
<ul className="text-blue-800 text-sm space-y-1">
|
||||
<li>• 前往詞卡管理頁面新增詞卡</li>
|
||||
<li>• 查看學習統計和進度</li>
|
||||
<li>• 調整學習目標和設定</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => router.push('/flashcards')}
|
||||
className="flex-1 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors font-medium"
|
||||
>
|
||||
管理詞卡
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push('/dashboard')}
|
||||
className="flex-1 py-3 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors font-medium"
|
||||
>
|
||||
回到首頁
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleRestart}
|
||||
className="w-full mt-3 py-2 text-blue-600 hover:text-blue-800 transition-colors text-sm"
|
||||
>
|
||||
🔄 重新檢查到期詞卡
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
|
|
|
|||
|
|
@ -2,14 +2,14 @@
|
|||
|
||||
interface ReviewTypeIndicatorProps {
|
||||
currentMode: string;
|
||||
userLevel?: number;
|
||||
wordLevel?: number;
|
||||
userCEFRLevel?: string;
|
||||
wordCEFRLevel?: string;
|
||||
}
|
||||
|
||||
export const ReviewTypeIndicator: React.FC<ReviewTypeIndicatorProps> = ({
|
||||
currentMode,
|
||||
userLevel,
|
||||
wordLevel
|
||||
userCEFRLevel,
|
||||
wordCEFRLevel
|
||||
}) => {
|
||||
const modeLabels = {
|
||||
'flip-memory': '翻卡記憶',
|
||||
|
|
@ -21,11 +21,22 @@ export const ReviewTypeIndicator: React.FC<ReviewTypeIndicatorProps> = ({
|
|||
'sentence-speaking': '例句口說'
|
||||
}
|
||||
|
||||
const getDifficultyLabel = (userLevel?: number, wordLevel?: number) => {
|
||||
if (!userLevel || !wordLevel) return '系統智能選擇';
|
||||
// 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 getDifficultyLabel = (userCEFR?: string, wordCEFR?: string) => {
|
||||
if (!userCEFR || !wordCEFR) return '系統智能選擇';
|
||||
|
||||
const userLevel = getCEFRToLevel(userCEFR);
|
||||
const wordLevel = getCEFRToLevel(wordCEFR);
|
||||
const difficulty = wordLevel - userLevel;
|
||||
if (userLevel <= 20) return 'A1學習者適配';
|
||||
|
||||
if (userCEFR === 'A1') return 'A1學習者適配';
|
||||
if (difficulty < -10) return '簡單詞彙練習';
|
||||
if (difficulty >= -10 && difficulty <= 10) return '適中詞彙練習';
|
||||
return '困難詞彙練習';
|
||||
|
|
@ -54,7 +65,7 @@ export const ReviewTypeIndicator: React.FC<ReviewTypeIndicatorProps> = ({
|
|||
{modeLabels[currentMode as keyof typeof modeLabels] || currentMode}
|
||||
</h3>
|
||||
<p className="text-sm text-blue-600">
|
||||
{getDifficultyLabel(userLevel, wordLevel)}
|
||||
{getDifficultyLabel(userCEFRLevel, wordCEFRLevel)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -62,9 +73,9 @@ export const ReviewTypeIndicator: React.FC<ReviewTypeIndicatorProps> = ({
|
|||
<div className="bg-blue-50 text-blue-700 px-3 py-1 rounded-full text-xs font-medium">
|
||||
系統智能選擇
|
||||
</div>
|
||||
{userLevel && wordLevel && (
|
||||
{userCEFRLevel && wordCEFRLevel && (
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
學習者程度: {userLevel} | 詞彙難度: {wordLevel}
|
||||
學習者等級: {userCEFRLevel} | 詞彙等級: {wordCEFRLevel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -180,8 +180,45 @@ class FlashcardsService {
|
|||
async getDueFlashcards(limit = 50): Promise<ApiResponse<Flashcard[]>> {
|
||||
try {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
return await this.makeRequest<ApiResponse<Flashcard[]>>(`/flashcards/due?date=${today}&limit=${limit}`);
|
||||
const response = await this.makeRequest<{ success: boolean; data: any[]; count: number }>(`/flashcards/due?date=${today}&limit=${limit}`);
|
||||
|
||||
// 轉換後端格式為前端期望格式
|
||||
const flashcards = response.data.map((card: any) => ({
|
||||
id: card.id,
|
||||
word: card.word,
|
||||
translation: card.translation,
|
||||
definition: card.definition,
|
||||
partOfSpeech: card.partOfSpeech,
|
||||
pronunciation: card.pronunciation,
|
||||
example: card.example,
|
||||
exampleTranslation: card.exampleTranslation,
|
||||
masteryLevel: card.masteryLevel || card.currentMasteryLevel || 0,
|
||||
timesReviewed: card.timesReviewed || 0,
|
||||
isFavorite: card.isFavorite || false,
|
||||
nextReviewDate: card.nextReviewDate,
|
||||
difficultyLevel: card.difficultyLevel || 'A2',
|
||||
createdAt: card.createdAt,
|
||||
updatedAt: card.updatedAt,
|
||||
// 智能複習擴展欄位
|
||||
userLevel: card.userLevel || 50,
|
||||
wordLevel: card.wordLevel || 50,
|
||||
baseMasteryLevel: card.baseMasteryLevel || card.masteryLevel || 0,
|
||||
lastReviewDate: card.lastReviewDate || card.lastReviewedAt,
|
||||
currentInterval: card.currentInterval || card.intervalDays || 1,
|
||||
isOverdue: card.isOverdue || false,
|
||||
overdueDays: card.overdueDays || 0,
|
||||
// 圖片相關欄位
|
||||
exampleImages: card.exampleImages || [],
|
||||
hasExampleImage: card.hasExampleImage || false,
|
||||
primaryImageUrl: card.primaryImageUrl
|
||||
}));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: flashcards
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('API request failed, using fallback:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get due flashcards',
|
||||
|
|
@ -200,17 +237,25 @@ class FlashcardsService {
|
|||
}
|
||||
}
|
||||
|
||||
async getOptimalReviewMode(cardId: string, userLevel: number, wordLevel: number): Promise<ApiResponse<{ selectedMode: string }>> {
|
||||
async getOptimalReviewMode(cardId: string, userCEFRLevel: string, wordCEFRLevel: string): Promise<ApiResponse<{ selectedMode: string }>> {
|
||||
try {
|
||||
return await this.makeRequest<ApiResponse<{ selectedMode: string }>>(`/flashcards/${cardId}/optimal-review-mode`, {
|
||||
const response = await this.makeRequest<{ success: boolean; data: any }>(`/flashcards/${cardId}/optimal-review-mode`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
userLevel,
|
||||
wordLevel,
|
||||
userCEFRLevel,
|
||||
wordCEFRLevel,
|
||||
includeHistory: true
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
success: response.success,
|
||||
data: {
|
||||
selectedMode: response.data.selectedMode
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Optimal review mode API failed, using fallback:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get optimal review mode',
|
||||
|
|
@ -226,14 +271,24 @@ class FlashcardsService {
|
|||
timeTaken?: number;
|
||||
}): Promise<ApiResponse<{ newInterval: number; nextReviewDate: string; masteryLevel: number }>> {
|
||||
try {
|
||||
return await this.makeRequest<ApiResponse<{ newInterval: number; nextReviewDate: string; masteryLevel: number }>>(`/flashcards/${id}/review`, {
|
||||
const response = await this.makeRequest<{ success: boolean; data: any }>(`/flashcards/${id}/review`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...reviewData,
|
||||
timestamp: Date.now()
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
success: response.success,
|
||||
data: {
|
||||
newInterval: response.data.newInterval || response.data.newIntervalDays || 1,
|
||||
nextReviewDate: response.data.nextReviewDate,
|
||||
masteryLevel: response.data.masteryLevel || response.data.newMasteryLevel || 0
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Submit review API failed, using fallback:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to submit review',
|
||||
|
|
|
|||
|
|
@ -33,36 +33,65 @@ export function getDecayAmount(baseMastery: number, currentMastery: number): num
|
|||
}
|
||||
|
||||
/**
|
||||
* 根據學習者程度和詞彙難度決定可用的複習方式
|
||||
* @param userLevel 學習者程度 (1-100)
|
||||
* @param wordLevel 詞彙難度 (1-100)
|
||||
* 根據學習者CEFR等級和詞彙CEFR等級決定可用的複習方式
|
||||
* @param userCEFRLevel 學習者CEFR等級 (A1-C2)
|
||||
* @param wordCEFRLevel 詞彙CEFR等級 (A1-C2)
|
||||
* @returns 適合的複習題型列表
|
||||
*/
|
||||
export function getReviewTypesByDifficulty(userLevel: number, wordLevel: number): string[] {
|
||||
export function getReviewTypesByDifficulty(userCEFRLevel: string, wordCEFRLevel: string): string[] {
|
||||
// 即時轉換CEFR為數值進行計算
|
||||
const userLevel = getCEFRToLevel(userCEFRLevel);
|
||||
const wordLevel = getCEFRToLevel(wordCEFRLevel);
|
||||
const difficulty = wordLevel - userLevel;
|
||||
|
||||
if (userLevel <= 20) {
|
||||
if (userCEFRLevel === 'A1') {
|
||||
// A1學習者 - 統一基礎題型
|
||||
return ['flip-memory', 'vocab-choice', 'vocab-listening'];
|
||||
} else if (difficulty < -10) {
|
||||
// 簡單詞彙 (學習者程度 > 詞彙程度)
|
||||
// 簡單詞彙 (學習者CEFR > 詞彙CEFR)
|
||||
return ['sentence-reorder', 'sentence-fill'];
|
||||
} else if (difficulty >= -10 && difficulty <= 10) {
|
||||
// 適中詞彙 (學習者程度 ≈ 詞彙程度)
|
||||
// 適中詞彙 (學習者CEFR ≈ 詞彙CEFR)
|
||||
return ['sentence-fill', 'sentence-reorder', 'sentence-speaking'];
|
||||
} else {
|
||||
// 困難詞彙 (學習者程度 < 詞彙程度)
|
||||
// 困難詞彙 (學習者CEFR < 詞彙CEFR)
|
||||
return ['flip-memory', 'vocab-choice'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 檢查是否為A1學習者
|
||||
* @param userLevel 學習者程度
|
||||
* @param userCEFRLevel 學習者CEFR等級
|
||||
* @returns 是否為A1學習者
|
||||
*/
|
||||
export function isA1Learner(userLevel: number): boolean {
|
||||
return userLevel <= 20;
|
||||
export function isA1Learner(userCEFRLevel: string): boolean {
|
||||
return userCEFRLevel === 'A1';
|
||||
}
|
||||
|
||||
/**
|
||||
* CEFR等級轉換為數值 (前端計算用)
|
||||
* @param cefr CEFR等級字符串 (A1-C2)
|
||||
* @returns 對應的數值 (20-95)
|
||||
*/
|
||||
export function 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等級 (前端顯示用)
|
||||
* @param level 數值 (20-95)
|
||||
* @returns CEFR等級字符串
|
||||
*/
|
||||
export function getLevelToCEFR(level: number): string {
|
||||
if (level <= 20) return 'A1';
|
||||
if (level <= 35) return 'A2';
|
||||
if (level <= 50) return 'B1';
|
||||
if (level <= 65) return 'B2';
|
||||
if (level <= 80) return 'C1';
|
||||
return 'C2';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue