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:
鄭沛軒 2025-09-25 21:05:54 +08:00
parent ff4c64f1a3
commit 52ae910276
4 changed files with 517 additions and 208 deletions

View File

@ -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ɪˈː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([])
setReorderResult(null)
// 只在卡片或模式切換時重置結果,不在其他狀態變化時重置
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>{`

View File

@ -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>

View File

@ -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',

View File

@ -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';
}
/**