feat: 添加重構計劃文檔和前端會話狀態優化

- 新增複習系統重構計劃文檔,詳細規劃後端驅動架構
- 優化前端學習頁面,添加詞卡複習會話狀態管理
- 實現測驗項目進度追蹤和任務清單彈出功能
- 清理過期文檔檔案
- 為後續重構奠定基礎

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
鄭沛軒 2025-09-26 13:37:34 +08:00
parent 50cf813400
commit 6c8c656dc3
5 changed files with 829 additions and 818 deletions

View File

@ -1,173 +0,0 @@
# 智能複習系統CEFR架構更新完成報告
## 📋 更新總結
**執行時間**: 2025-09-25
**狀態**: ✅ **成功完成**
**架構**: 從雙欄位架構改為純CEFR字符串 + 即時轉換
**前端地址**: http://localhost:3002/learn
**後端地址**: http://localhost:5008
## 🎯 更新目標達成
### ✅ 移除資料冗余問題
- **原架構**: CEFR字符串 + 數值欄位 (資料重複)
- **新架構**: CEFR字符串 + 即時轉換 (消除冗余)
- **效果**: 簡化資料庫結構,減少維護負擔
### ✅ 符合CEFR國際標準
- **用戶程度**: 基於User.EnglishLevel (A1-C2)
- **詞彙難度**: 基於Flashcard.DifficultyLevel (A1-C2)
- **顯示邏輯**: 完全使用標準CEFR術語
## 🔧 具體實施成果
### **後端架構更新** ✅ **完成**
#### **1. API接口改為CEFR字符串**
```csharp
// OptimalModeRequest.cs - 新的請求格式
public class OptimalModeRequest
{
public string UserCEFRLevel { get; set; } = "B1"; // A1-C2字符串
public string WordCEFRLevel { get; set; } = "B1"; // A1-C2字符串
public bool IncludeHistory { get; set; } = true;
}
```
#### **2. ReviewTypeSelectorService即時轉換**
```csharp
// 接收CEFR字符串內部即時轉換為數值計算
public async Task<ReviewModeResult> SelectOptimalReviewModeAsync(
Guid flashcardId, string userCEFRLevel, string wordCEFRLevel)
{
var userLevel = CEFRMappingService.GetWordLevel(userCEFRLevel); // A2→35
var wordLevel = CEFRMappingService.GetWordLevel(wordCEFRLevel); // A2→35
// 使用數值進行算法計算...
}
```
### **前端架構更新** ✅ **完成**
#### **1. API服務層使用CEFR字符串**
```typescript
// flashcards.ts - 新的API呼叫方式
async getOptimalReviewMode(cardId: string, userCEFRLevel: string, wordCEFRLevel: string) {
return await this.makeRequest(`/flashcards/${cardId}/optimal-review-mode`, {
method: 'POST',
body: JSON.stringify({
userCEFRLevel, // "A2"
wordCEFRLevel, // "B1"
includeHistory: true
}),
});
}
```
#### **2. 學習頁面使用CEFR字符串**
```typescript
// learn/page.tsx - 智能選擇使用CEFR
const selectOptimalReviewMode = async (card: ExtendedFlashcard) => {
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
);
}
```
#### **3. 前端組件CEFR顯示**
```typescript
// ReviewTypeIndicator.tsx - 顯示標準CEFR等級
<ReviewTypeIndicator
currentMode={mode}
userCEFRLevel="A2" // 顯示CEFR字符串
wordCEFRLevel="B1" // 顯示CEFR字符串
/>
// 顯示: "學習者等級: A2 | 詞彙等級: B1"
```
## 🧪 驗證測試結果
### **API測試成功**
```bash
✅ CEFR智能選擇成功:
用戶等級: A2
詞彙等級: A2
選擇題型: sentence-speaking
適配情境: 適中詞彙
選擇理由: 適中詞彙進行全方位練習
✅ 復習結果提交成功:
新的熟悉度: 23
下次復習日期: 2025-09-26T00:00:00+08:00
```
### **後端日誌驗證**
```
Selecting optimal review mode for flashcard ..., userCEFR: A2, wordCEFR: A2
CEFR converted to levels: A2→35, A2→35
```
### **前端顯示更新**
- 學習者等級: 顯示 "A2" (而非數值35)
- 詞彙等級: 顯示 "A2" (而非數值35)
- 情境判斷: "適中詞彙" (基於CEFR等級差異)
## 🚀 架構優化成果
### **技術優勢** ✅ **實現**
- ✅ **消除資料冗余**: 不再需要維護數值和CEFR兩套欄位
- ✅ **符合國際標準**: 完全使用標準CEFR等級術語
- ✅ **提升可讀性**: API和UI都使用CEFR更直觀
- ✅ **簡化維護**: 只需維護一套CEFR字符串欄位
### **性能表現** ✅ **優異**
- ✅ **即時轉換**: CEFRMappingService轉換極快 (< 1ms)
- ✅ **API響應**: 整體響應時間無影響
- ✅ **算法準確**: 四情境判斷100%正確
- ✅ **用戶體驗**: 顯示更加直觀和標準
### **系統穩定性** ✅ **優良**
- ✅ **向後相容**: 保留數值計算邏輯作為內部實現
- ✅ **錯誤處理**: 完善的CEFR驗證和預設值
- ✅ **測試通過**: API整合測試100%成功
## 📊 更新前後對比
### **更新前 (雙欄位架構)**
```
❌ 複雜: User.EnglishLevel + Flashcard.UserLevel
❌ 冗余: Flashcard.DifficultyLevel + Flashcard.WordLevel
❌ 維護: 需要同步兩套欄位
❌ 混亂: API使用數值顯示使用CEFR
```
### **更新後 (純CEFR架構)**
```
✅ 簡潔: User.EnglishLevel (CEFR字符串)
✅ 標準: Flashcard.DifficultyLevel (CEFR字符串)
✅ 一致: API和顯示都使用CEFR
✅ 高效: 即時轉換,無性能問題
```
## 🎉 最終成果
**智能複習系統CEFR架構更新圓滿完成** 🚀
### **✅ 達成效果**
1. **消除資料冗余**: 系統更簡潔,維護更容易
2. **標準化實現**: 完全符合CEFR國際標準
3. **用戶體驗提升**: 顯示更直觀,專業感更強
4. **技術債務清理**: 移除不必要的複雜性
### **🔧 系統現狀**
- **後端**: 純CEFR字符串API即時轉換計算
- **前端**: 標準CEFR顯示智能適配正常
- **資料庫**: 待移除冗余數值欄位 (UserLevel, WordLevel)
- **性能**: 優異,轉換開銷微乎其微
**系統已準備投入生產使用CEFR架構更加專業和標準** 📚✅

View File

@ -11,6 +11,26 @@ 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<Flashcard, 'nextReviewDate'> {
nextReviewDate?: string; // 下次復習日期 (可選)
@ -24,6 +44,26 @@ interface ExtendedFlashcard extends Omit<Flashcard, 'nextReviewDate'> {
// 注意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)
@ -49,6 +89,13 @@ export default function LearnPage() {
// 測驗進度狀態
const [totalTests, setTotalTests] = useState(0) // 所有測驗總數
const [completedTests, setCompletedTests] = useState(0) // 已完成測驗數
const [testItems, setTestItems] = useState<TestItem[]>([]) // 測驗項目列表
const [currentTestItemIndex, setCurrentTestItemIndex] = useState(0) // 當前測驗項目索引
// 詞卡複習會話狀態
const [cardReviewSessions, setCardReviewSessions] = useState<Map<string, CardReviewSession>>(new Map())
const [currentCardSession, setCurrentCardSession] = useState<CardReviewSession | null>(null)
const [completedCards, setCompletedCards] = useState(0) // 已完成復習的詞卡數
// UI狀態
const [modalImage, setModalImage] = useState<string | null>(null)
@ -57,6 +104,7 @@ export default function LearnPage() {
const [reportingCard, setReportingCard] = useState<any>(null)
const [showComplete, setShowComplete] = useState(false)
const [showNoDueCards, setShowNoDueCards] = useState(false)
const [showTaskListModal, setShowTaskListModal] = useState(false)
const [cardHeight, setCardHeight] = useState<number>(400)
// 題型特定狀態
@ -144,16 +192,40 @@ export default function LearnPage() {
setCompletedTests(0);
setDueCards(cardsToUse);
// 生成測驗項目列表
const testItemsList = generateTestItems(cardsToUse, userCEFR);
setTestItems(testItemsList);
setCurrentTestItemIndex(0);
console.log('📝 測驗項目列表生成:', testItemsList.length, '個項目');
console.log('🎯 測驗項目詳情:', testItemsList.map(item =>
`${item.order}. ${item.word} - ${item.testName}`
));
// 設置第一張卡片
const firstCard = cardsToUse[0];
setCurrentCard(firstCard);
setCurrentCardIndex(0);
// 開始第一張詞卡的複習會話
startCardReviewSession(firstCard);
// 系統自動選擇模式
const selectedMode = await selectOptimalReviewMode(firstCard);
setMode(selectedMode);
setIsAutoSelecting(false);
// 標記第一個測驗項目為當前狀態
if (testItemsList.length > 0) {
setTestItems(prev =>
prev.map((item, index) =>
index === 0
? { ...item, isCurrent: true }
: item
)
);
}
console.log(`🎯 初始載入: ${firstCard.word}, 選擇模式: ${selectedMode}`);
} else {
// 沒有到期詞卡
@ -396,6 +468,105 @@ export default function LearnPage() {
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<string, CardTestGroup>);
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);
@ -538,8 +709,8 @@ export default function LearnPage() {
total: prev.total + 1
}))
// 提交復習結果
await submitReviewResult(isCorrect, userSentence);
// 記錄測驗結果
recordTestResult(isCorrect, userSentence);
}
const handleResetReorder = () => {
@ -556,15 +727,60 @@ export default function LearnPage() {
setIsFlipped(!isFlipped)
}
// 移動到下一個測驗或下一張詞卡
const handleNext = async () => {
if (currentCardIndex < dueCards.length - 1) {
await loadNextCardWithAutoMode(currentCardIndex + 1);
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 {
setShowComplete(true);
// 當前詞卡的所有測驗都已完成,移動到下一張詞卡
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);
}
@ -582,46 +798,135 @@ export default function LearnPage() {
total: prev.total + 1
}))
// 提交復習結果到後端
await submitReviewResult(isCorrect, answer);
// 記錄測驗結果到本地會話
recordTestResult(isCorrect, answer);
}
// 提交復習結果並更新測驗進度
const submitReviewResult = async (isCorrect: boolean, userAnswer?: string, confidenceLevel?: number) => {
if (!currentCard) return;
// 記錄測驗結果到本地會話(不提交到後端)
const recordTestResult = (isCorrect: boolean, userAnswer?: string, confidenceLevel?: number) => {
if (!currentCard || !currentCardSession) return;
const testResult: TestResult = {
testType: mode,
isCorrect,
userAnswer,
confidenceLevel,
responseTimeMs: 2000, // 簡化時間計算,稍後可改進
completedAt: new Date()
};
// 更新當前會話的測驗結果
const updatedSession = {
...currentCardSession,
completedTests: [...currentCardSession.completedTests, testResult]
};
// 檢查是否完成所有預定測驗
const isAllTestsCompleted = updatedSession.completedTests.length >= updatedSession.plannedTests.length;
if (isAllTestsCompleted) {
updatedSession.isCompleted = true;
}
setCurrentCardSession(updatedSession);
// 更新會話映射
setCardReviewSessions(prev => new Map(prev.set(currentCard.id, updatedSession)));
// 更新測驗進度
setCompletedTests(prev => {
const newCompleted = prev + 1;
console.log(`📈 測驗進度更新: ${newCompleted}/${totalTests} (${Math.round((newCompleted/totalTests)*100)}%)`);
return newCompleted;
});
// 標記當前測驗項目為完成
setTestItems(prev =>
prev.map((item, index) =>
index === currentTestItemIndex
? { ...item, isCompleted: true, isCurrent: false }
: item
)
);
// 移到下一個測驗項目
setCurrentTestItemIndex(prev => prev + 1);
console.log(`🔍 記錄測驗結果:`, {
word: currentCard.word,
testType: mode,
isCorrect,
completedTests: updatedSession.completedTests.length,
plannedTests: updatedSession.plannedTests.length,
isCardCompleted: updatedSession.isCompleted
});
// 如果詞卡的所有測驗都完成了,觸發完整復習邏輯
if (updatedSession.isCompleted) {
console.log(`✅ 詞卡 ${currentCard.word} 的所有測驗已完成,準備提交復習結果`);
completeCardReview(updatedSession);
}
}
// 完成詞卡複習並提交到後端
const completeCardReview = async (session: CardReviewSession) => {
try {
const result = await flashcardsService.submitReview(currentCard.id, {
isCorrect,
confidenceLevel,
questionType: mode,
userAnswer,
timeTaken: Date.now() - Date.now() // 簡化時間計算
// 計算綜合表現指標
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);
// 更新卡片的熟悉度等資訊,但不觸發卡片重新載入
setCurrentCard(prev => prev ? {
...prev,
masteryLevel: result.data!.masteryLevel,
nextReviewDate: result.data!.nextReviewDate
} : null);
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.log('復習結果提交失敗,繼續運行');
console.error('詞卡復習結果提交失敗:', result.error);
}
// 更新測驗進度(無論提交成功或失敗)
setCompletedTests(prev => {
const newCompleted = prev + 1;
console.log(`📈 測驗進度更新: ${newCompleted}/${totalTests} (${Math.round((newCompleted/totalTests)*100)}%)`);
return newCompleted;
});
} catch (error) {
console.error('提交復習結果失敗:', error);
// 即使出錯也更新進度,避免卡住
setCompletedTests(prev => prev + 1);
console.error('完成詞卡復習時發生錯誤:', error);
}
}
@ -636,8 +941,8 @@ export default function LearnPage() {
total: prev.total + 1
}))
// 提交復習結果
await submitReviewResult(isCorrect, fillAnswer);
// 記錄測驗結果
recordTestResult(isCorrect, fillAnswer);
}
const handleListeningAnswer = async (answer: string) => {
@ -652,8 +957,8 @@ export default function LearnPage() {
total: prev.total + 1
}))
// 提交復習結果
await submitReviewResult(isCorrect, answer);
// 記錄測驗結果
recordTestResult(isCorrect, answer);
}
const handleSpeakingAnswer = async (transcript: string) => {
@ -667,8 +972,8 @@ export default function LearnPage() {
total: prev.total + 1
}))
// 提交復習結果
await submitReviewResult(isCorrect, transcript);
// 記錄測驗結果
recordTestResult(isCorrect, transcript);
}
const handleSentenceListeningAnswer = async (answer: string) => {
@ -683,8 +988,8 @@ export default function LearnPage() {
total: prev.total + 1
}))
// 提交復習結果
await submitReviewResult(isCorrect, answer);
// 記錄測驗結果
recordTestResult(isCorrect, answer);
}
const handleReportSubmit = () => {
@ -701,6 +1006,8 @@ export default function LearnPage() {
setScore({ correct: 0, total: 0 })
setCompletedTests(0)
setTotalTests(0)
setTestItems([])
setCurrentTestItemIndex(0)
setShowComplete(false)
setShowNoDueCards(false)
await loadDueCards(); // 重新載入到期詞卡
@ -787,173 +1094,78 @@ export default function LearnPage() {
<div className="max-w-4xl mx-auto px-4 py-8">
{/* Progress Bar */}
<div className="mb-8">
<div className="flex justify-between items-center mb-2">
<span className="text-sm text-gray-600"></span>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">
{completedTests} / {totalTests}
</span>
<span className="text-xs text-gray-500">
{currentCardIndex + 1}/{dueCards.length}
</span>
<div className="text-sm">
<span className="text-green-600 font-semibold">{score.correct}</span>
<div className="flex justify-between items-center mb-3">
<span className="text-sm font-medium text-gray-900"></span>
<div className="flex items-center gap-6">
{/* 詞卡進度 */}
<div className="text-sm text-gray-600">
<span className="font-medium">:</span>
<span className="ml-1 text-green-600 font-semibold">{completedCards}</span>
<span className="text-gray-500">/</span>
<span className="text-gray-600">{score.total}</span>
{score.total > 0 && (
<span className="text-blue-600 ml-2">
({Math.round((score.correct / score.total) * 100)}%)
<span className="text-gray-600">{dueCards.length}</span>
{dueCards.length > 0 && (
<span className="text-blue-600 ml-1">
({Math.round((completedCards / dueCards.length) * 100)}%)
</span>
)}
</div>
</div>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all"
style={{ width: `${totalTests > 0 ? (completedTests / totalTests) * 100 : 0}%` }}
></div>
</div>
</div>
{/* Smart Review Information Panel */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<h3 className="text-lg font-semibold text-blue-900 mb-3">🧠 CEFR智能複習系統</h3>
<div className="grid grid-cols-1 md:grid-cols-4 gap-3 text-sm mb-4">
<div className="bg-white rounded p-3">
<div className="text-blue-700 font-medium"></div>
<div className="text-gray-600">
{currentCard && (() => {
const userCEFRLevel = localStorage.getItem('userEnglishLevel') || 'A2';
const wordCEFRLevel = currentCard.difficultyLevel || 'A2';
const userLevel = getCEFRToLevel(userCEFRLevel);
const wordLevel = getCEFRToLevel(wordCEFRLevel);
const difficulty = wordLevel - userLevel;
if (userCEFRLevel === 'A1') return 'A1學習者';
if (difficulty < -10) return '簡單詞彙';
if (difficulty >= -10 && difficulty <= 10) return '適中詞彙';
return '困難詞彙';
})()}
</div>
</div>
<div className="bg-white rounded p-3">
<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?.difficultyLevel || 'A2'}</div>
</div>
<div className="bg-white rounded p-3">
<div className="text-blue-700 font-medium"></div>
<div className="text-gray-600">
{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>
{/* 測驗進度 */}
<button
onClick={() => setShowTaskListModal(true)}
className="text-sm text-gray-600 hover:text-blue-600 transition-colors cursor-pointer flex items-center gap-1"
title="點擊查看詳細任務清單"
>
<span className="font-medium">:</span>
<span className="ml-1 text-blue-600 font-semibold">{completedTests}</span>
<span className="text-gray-500">/</span>
<span className="text-gray-600">{totalTests}</span>
{totalTests > 0 && (
<span className="text-blue-600 ml-1">
({Math.round((completedTests / totalTests) * 100)}%)
</span>
)}
<span className="text-xs ml-1">📋</span>
</button>
</div>
</div>
{/* 當前選擇突出顯示 */}
<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 className="space-y-2">
{/* 詞卡進度條 */}
<div className="flex items-center gap-3">
<span className="text-xs text-gray-500 w-12"></span>
<div className="flex-1 bg-gray-200 rounded-full h-2">
<div
className="bg-green-500 h-2 rounded-full transition-all"
style={{ width: `${dueCards.length > 0 ? (completedCards / dueCards.length) * 100 : 0}%` }}
></div>
</div>
<span className="text-xs text-gray-500 w-8">
{dueCards.length > 0 ? Math.round((completedCards / dueCards.length) * 100) : 0}%
</span>
</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 className="flex items-center gap-3">
<span className="text-xs text-gray-500 w-12"></span>
<div
className="flex-1 bg-gray-200 rounded-full h-2 cursor-pointer hover:bg-gray-300 transition-colors"
onClick={() => setShowTaskListModal(true)}
title="點擊查看詳細任務清單"
>
<div
className="bg-blue-500 h-2 rounded-full transition-all hover:bg-blue-600"
style={{ width: `${totalTests > 0 ? (completedTests / totalTests) * 100 : 0}%` }}
></div>
</div>
<span className="text-xs text-gray-500 w-8">
{totalTests > 0 ? Math.round((completedTests / totalTests) * 100) : 0}%
</span>
</div>
</div>
</div>
{/* Current Card Mastery Level */}
{currentCard?.baseMasteryLevel && currentCard?.lastReviewDate && (
<div className="mb-4">
<MasteryIndicator
level={calculateCurrentMastery(currentCard.baseMasteryLevel, currentCard.lastReviewDate)}
baseMasteryLevel={currentCard.baseMasteryLevel}
size="medium"
showPercentage={true}
/>
</div>
)}
{mode === 'flip-memory' ? (
/* Flip Card Mode */
<div className="relative">
@ -1885,6 +2097,111 @@ export default function LearnPage() {
</div>
)}
{/* Task List Modal */}
{showTaskListModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-2xl max-w-4xl w-full mx-4 max-h-[80vh] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
📚
</h2>
<button
onClick={() => setShowTaskListModal(false)}
className="text-gray-400 hover:text-gray-600 text-2xl"
>
</button>
</div>
{/* Content */}
<div className="p-6 overflow-y-auto max-h-[60vh]">
{/* 進度統計 */}
<div className="mb-6 bg-blue-50 rounded-lg p-4">
<div className="flex items-center justify-between text-sm">
<span className="text-blue-900 font-medium">
: {completedTests} / {totalTests} ({totalTests > 0 ? Math.round((completedTests / totalTests) * 100) : 0}%)
</span>
<div className="flex items-center gap-4 text-blue-800">
<span> : {testItems.filter(item => item.isCompleted).length}</span>
<span> : {testItems.filter(item => item.isCurrent).length}</span>
<span> : {testItems.filter(item => !item.isCompleted && !item.isCurrent).length}</span>
</div>
</div>
<div className="mt-3 w-full bg-blue-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all"
style={{ width: `${totalTests > 0 ? (completedTests / totalTests) * 100 : 0}%` }}
></div>
</div>
</div>
{/* 任務清單 */}
<div className="space-y-4">
{groupTestItemsByCard(testItems).map((cardGroup, cardIndex) => (
<div key={cardGroup.cardId} className="border border-gray-200 rounded-lg p-4 bg-white shadow-sm">
{/* 詞卡標題 */}
<div className="flex items-center gap-3 mb-3">
<span className="font-medium text-gray-900">
{cardIndex + 1}: {cardGroup.word}
</span>
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded">
{cardGroup.context}
</span>
<span className="text-xs text-gray-500">
{cardGroup.tests.length}
</span>
</div>
{/* 測驗項目 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
{cardGroup.tests.map(test => (
<div
key={test.id}
className={`flex items-center gap-3 p-3 rounded-lg transition-all ${
test.isCompleted
? 'bg-green-50 border border-green-200'
: test.isCurrent
? 'bg-blue-50 border border-blue-300 shadow-sm'
: 'bg-gray-50 border border-gray-200'
}`}
>
{/* 狀態圖標 */}
<span className="text-lg">
{test.isCompleted ? '✅' : test.isCurrent ? '⏳' : '⚪'}
</span>
{/* 測驗資訊 */}
<div className="flex-1">
<div className="font-medium text-sm">
{test.order}. {test.testName}
</div>
<div className={`text-xs ${
test.isCompleted ? 'text-green-600' :
test.isCurrent ? 'text-blue-600' : 'text-gray-500'
}`}>
{test.isCompleted ? '已完成' :
test.isCurrent ? '進行中' : '待完成'}
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
{testItems.length === 0 && (
<div className="text-center py-8 text-gray-500">
<div className="text-4xl mb-2">📚</div>
<p></p>
</div>
)}
</div>
</div>
</div>
)}
{/* Complete Modal */}
{showComplete && (
<LearningComplete
@ -1942,6 +2259,7 @@ export default function LearnPage() {
</div>
</div>
)}
</div>
<style jsx>{`

View File

@ -1,179 +0,0 @@
# 冗餘UserLevel/WordLevel欄位移除完成報告
## 📋 **執行總結**
**執行時間**: 2025-09-26
**狀態**: ✅ **完全成功**
**架構**: 純CEFR字符串架構
**前端**: http://localhost:3002
**後端**: http://localhost:5008
---
## 🎯 **移除目標達成**
### **✅ 消除資料重複問題**
```sql
-- 移除前:重複存儲
users.english_level: "A2" (主要)
flashcards.UserLevel: 50 (冗餘) ← 已移除
flashcards.difficulty_level: "A2" (主要)
flashcards.WordLevel: 35 (冗餘) ← 已移除
-- 移除後純CEFR架構
users.english_level: "A2" (唯一來源)
flashcards.difficulty_level: "A2" (唯一來源)
```
### **✅ 程式碼簡化成果**
- **FlashcardsController**: 移除數值欄位初始化邏輯
- **SpacedRepetitionService**: 移除批量初始化程式碼
- **前端接口**: 移除數值欄位映射
- **資料庫模型**: 移除冗餘屬性定義
---
## 🔧 **具體實施成果**
### **Phase 1: 後端資料庫清理** ✅ **完成**
#### **1. 資料庫遷移執行**
```bash
✅ 創建遷移: dotnet ef migrations add RemoveRedundantLevelFields
✅ 執行遷移: dotnet ef database update
✅ 欄位移除: UserLevel, WordLevel從flashcards表移除
```
#### **2. Flashcard模型更新**
```csharp
// 移除前:
public int UserLevel { get; set; } = 50;
public int WordLevel { get; set; } = 50;
// 移除後:
// UserLevel和WordLevel已移除 - 改用即時CEFR轉換
```
#### **3. Controller邏輯清理**
```csharp
// 移除前:數值欄位初始化
if (nextCard.UserLevel == 0) nextCard.UserLevel = ...;
if (nextCard.WordLevel == 0) nextCard.WordLevel = ...;
// 移除後:
// UserLevel和WordLevel欄位已移除 - 改用即時CEFR轉換
```
### **Phase 2: 前端接口適配** ✅ **完成**
#### **1. API服務層更新**
```typescript
// 移除前:包含數值欄位映射
userLevel: card.userLevel || 50,
wordLevel: card.wordLevel || 50,
// 移除後:
// 智能複習擴展欄位 (數值欄位已移除改用即時CEFR轉換)
```
#### **2. 接口定義簡化**
```typescript
// 移除前:
interface ExtendedFlashcard {
userLevel?: number;
wordLevel?: number;
}
// 移除後:
interface ExtendedFlashcard {
// 注意userLevel和wordLevel已移除改用即時CEFR轉換
}
```
---
## 🧪 **功能驗證結果**
### **✅ API測試通過**
```bash
✅ GET /flashcards/due:
- success: true, count: 5
- hasUserLevel: false, hasWordLevel: false
- 確認數值欄位已完全移除
✅ POST /flashcards/{id}/optimal-review-mode:
- userCEFR: "A2" → 智能選擇: "sentence-reorder"
- adaptationContext: "適中詞彙"
- 純CEFR字符串智能選擇100%正常
```
### **✅ 即時轉換驗證**
```csharp
// 後端日誌確認:
CEFR converted to levels: A2→35, A2→35
Selected mode: sentence-reorder, context: 適中詞彙
```
### **✅ 前端功能正常**
- 學習頁面載入正常
- 四情境對照表顯示正確
- 智能適配完全正常
- 播放按鈕統一設計正常
---
## 📊 **架構優化成果**
### **資料庫優化**
- **移除冗餘欄位**: UserLevel, WordLevel
- **減少存儲空間**: 每張詞卡節省8 bytes
- **消除同步負擔**: 不需要維護數值和CEFR同步
- **符合正規化**: 遵循資料庫設計最佳實踐
### **程式碼品質提升**
- **移除重複邏輯**: 約50行冗餘程式碼
- **統一CEFR處理**: 全系統使用標準CEFR術語
- **降低複雜度**: 不需要管理雙欄位邏輯
- **提升可維護性**: 單一資料來源原則
### **架構純化**
- **純CEFR標準**: 完全符合國際語言學習標準
- **即時轉換**: CEFRMappingService高效轉換< 1ms
- **無性能影響**: 轉換開銷微乎其微
- **標準化API**: 前後端統一使用CEFR術語
---
## 🎉 **最終成果**
### **✅ 技術債務清理完成**
- 徹底解決資料重複問題
- 消除維護負擔和同步風險
- 提升系統架構純度
### **✅ CEFR標準化達成**
- 全系統統一使用標準CEFR等級
- 符合國際語言學習慣例
- 提升專業度和可信度
### **✅ 系統性能優化**
- 移除冗餘資料存儲
- 簡化資料庫結構
- 降低記憶體使用
### **🚀 系統現狀**
- **資料庫**: 純CEFR字符串無冗餘欄位
- **後端**: 即時轉換邏輯,高效能計算
- **前端**: 純CEFR顯示統一播放按鈕
- **功能**: 智能複習系統100%正常運作
**冗餘數值欄位移除計劃圓滿完成智能複習系統現已達到純CEFR標準化架構** 🎯✨
---
**技術架構優化前後對比**:
```
優化前: CEFR字符串 + 數值欄位 (重複資料)
優化後: 純CEFR字符串 + 即時轉換 (標準化)
```
**系統已準備投入生產使用,架構純淨、標準、高效!** 🚀📚

View File

@ -1,272 +0,0 @@
# 移除冗餘UserLevel/WordLevel欄位和程式碼計劃
## 🎯 **目標**
徹底移除冗餘的數值欄位簡化資料庫結構實現純CEFR字符串架構消除資料重複問題。
---
## 📊 **現況分析**
### **重複資料確認**
```sql
-- 用戶程度重複存儲 ❌
users.english_level: "A2" (主要標準CEFR)
flashcards.UserLevel: 50 (冗餘,數值緩存)
-- 詞彙難度重複存儲 ❌
flashcards.difficulty_level: "A2" (主要標準CEFR)
flashcards.WordLevel: 35 (冗餘,數值緩存)
```
### **冗餘程度評估**
- **智能選擇**: ✅ 已改為即時CEFR轉換不使用存儲數值
- **四情境判斷**: ✅ 使用即時轉換的數值進行運算
- **API回應**: ⚠️ 仍包含數值欄位(僅為前端相容)
- **舊資料處理**: ⚠️ 防止數值為0的初始化邏輯
---
## 📋 **詳細移除計劃**
### **Phase 1: 後端資料庫和模型清理** ⏱️ 1天
#### **1.1 創建資料庫遷移**
```bash
cd backend/DramaLing.Api
dotnet ef migrations add RemoveRedundantLevelFields
```
**Migration內容**:
```sql
ALTER TABLE flashcards DROP COLUMN UserLevel;
ALTER TABLE flashcards DROP COLUMN WordLevel;
```
#### **1.2 更新Flashcard模型**
**檔案**: `Models/Entities/Flashcard.cs`
```csharp
// 移除這兩個屬性:
// [Range(1, 100)]
// public int UserLevel { get; set; } = 50;
//
// [Range(1, 100)]
// public int WordLevel { get; set; } = 50;
```
#### **1.3 清理配置選項**
**檔案**: `appsettings.json`
```json
{
"SpacedRepetition": {
// 移除 "DefaultUserLevel": 50
}
}
```
**檔案**: `Models/Configuration/SpacedRepetitionOptions.cs`
```csharp
// 移除 DefaultUserLevel 屬性
```
### **Phase 2: 後端API和服務清理** ⏱️ 1天
#### **2.1 清理FlashcardsController**
**檔案**: `Controllers/FlashcardsController.cs`
**移除數值欄位初始化**:
```csharp
// 移除 lines 508-512:
// if (nextCard.UserLevel == 0)
// nextCard.UserLevel = CEFRMappingService.GetDefaultUserLevel();
// if (nextCard.WordLevel == 0)
// nextCard.WordLevel = CEFRMappingService.GetWordLevel(nextCard.DifficultyLevel);
```
**簡化API回應**:
```csharp
var response = new
{
// 移除 nextCard.UserLevel, nextCard.WordLevel
// 保留 nextCard.DifficultyLevel (CEFR字符串)
};
```
#### **2.2 清理SpacedRepetitionService**
**檔案**: `Services/SpacedRepetitionService.cs`
**移除批量初始化邏輯**:
```csharp
// 移除 lines 141-149:
// foreach (var card in dueCards.Where(c => c.WordLevel == 0))
// {
// card.WordLevel = CEFRMappingService.GetWordLevel(card.DifficultyLevel);
// if (card.UserLevel == 0)
// card.UserLevel = _options.DefaultUserLevel;
// }
```
#### **2.3 清理QuestionGeneratorService**
**檔案**: `Services/QuestionGeneratorService.cs`
**移除數值版本的方法** (如果存在):
```csharp
// 檢查並移除任何直接使用數值參數的方法
```
### **Phase 3: 前端適配調整** ⏱️ 0.5天
#### **3.1 更新前端API服務**
**檔案**: `frontend/lib/services/flashcards.ts`
**移除數值欄位映射**:
```typescript
const flashcards = response.data.map((card: any) => ({
// 移除這兩行:
// userLevel: card.userLevel || 50,
// wordLevel: card.wordLevel || 50,
}));
```
#### **3.2 更新前端接口定義**
**檔案**: `frontend/app/learn/page.tsx`
**簡化ExtendedFlashcard**:
```typescript
interface ExtendedFlashcard extends Omit<Flashcard, 'nextReviewDate'> {
// 移除:
// userLevel?: number;
// wordLevel?: number;
nextReviewDate?: string;
// ...其他實際需要的欄位
}
```
#### **3.3 更新前端顯示邏輯**
**全部改為CEFR字符串邏輯**:
```typescript
// 不再使用 currentCard.userLevel, currentCard.wordLevel
// 改為:
const userCEFR = localStorage.getItem('userEnglishLevel') || 'A2';
const wordCEFR = currentCard.difficultyLevel || 'A2';
```
### **Phase 4: API接口純化** ⏱️ 0.5天
#### **4.1 移除API回應中的數值欄位**
**所有相關API端點**:
- `GET /flashcards/due`
- `GET /flashcards/next-review`
- `GET /flashcards/{id}`
**移除回應中的**:
```json
{
// 移除 "userLevel": 50,
// 移除 "wordLevel": 35,
"difficultyLevel": "A2" // 保留CEFR字符串
}
```
#### **4.2 更新API文檔**
- 移除數值欄位的相關描述
- 更新為純CEFR架構文檔
### **Phase 5: 測試和驗證** ⏱️ 0.5天
#### **5.1 功能測試清單**
- [ ] 智能複習選擇功能正常
- [ ] 四情境判斷邏輯正確
- [ ] 前端顯示完全正常
- [ ] CEFR等級轉換準確
- [ ] 新詞卡創建和更新正常
#### **5.2 性能測試**
- [ ] API回應時間無明顯變化
- [ ] 智能選擇速度正常
- [ ] 前端載入速度正常
#### **5.3 回歸測試**
- [ ] 學習頁面完整流程
- [ ] 詞卡管理功能正常
- [ ] 所有播放按鈕正常
---
## 🗂️ **檔案修改清單**
### **後端檔案** (5個主要檔案)
1. `Models/Entities/Flashcard.cs` - 移除數值屬性
2. `Controllers/FlashcardsController.cs` - 移除初始化和回應邏輯
3. `Services/SpacedRepetitionService.cs` - 移除批量初始化
4. `Models/Configuration/SpacedRepetitionOptions.cs` - 移除配置
5. `appsettings.json` - 移除配置項
### **前端檔案** (3個主要檔案)
1. `lib/services/flashcards.ts` - 移除數值映射
2. `app/learn/page.tsx` - 更新接口和邏輯
3. `components/review/ReviewTypeIndicator.tsx` - 移除數值依賴
### **資料庫遷移** (1個檔案)
1. 新的migration檔案 - DROP COLUMN指令
---
## 📈 **預期效益**
### **資料庫優化**
- ✅ 移除2個冗餘INT欄位
- ✅ 消除資料同步負擔
- ✅ 減少儲存空間使用
- ✅ 簡化資料庫結構
### **程式碼簡化**
- ✅ 移除50行冗餘程式碼
- ✅ 消除資料同步邏輯
- ✅ 統一CEFR處理流程
- ✅ 提升程式碼可讀性
### **架構純化**
- ✅ 純CEFR標準架構
- ✅ 符合資料庫正規化原則
- ✅ 消除資料重複問題
- ✅ 降低維護複雜度
---
## ⚠️ **風險管理**
### **風險等級**: 🟢 **低風險**
- CEFR轉換邏輯已穩定運行
- 即時轉換性能優異
- 不影響用戶體驗
### **緩解措施**
- 保留migration回滾腳本
- 分階段實施,逐步驗證
- 保持完整測試覆蓋
- 監控性能指標
### **回滾計劃**
如有問題可快速回滾:
```sql
-- 回滾migration恢復欄位
dotnet ef database update PreviousMigration
```
---
## 🚀 **實施建議**
### **推薦立即實施**
1. **技術債務清理**: 消除設計上的冗餘
2. **標準化架構**: 完全符合CEFR國際標準
3. **長期維護**: 降低未來開發和維護成本
4. **代碼品質**: 提升整體架構清潔度
### **實施順序**
1. **後端清理****前端適配** → **測試驗證**
2. 可隨時暫停,每個階段都有明確的檢查點
3. 出現問題立即回滾,影響可控
**建議:開始實施此清理計劃,徹底解決資料重複問題!** 🎯

317
複習系統重構計劃.md Normal file
View File

@ -0,0 +1,317 @@
# 複習系統後端驅動架構重構計劃
## 專案概述
### 問題現狀
1. **邏輯錯誤**:完成一個測驗就標記詞卡完成,但實際還有其他測驗未完成
2. **架構問題**:前端管理複習會話狀態,容易出現數據不一致
3. **UI問題**:雙進度條視覺效果不佳,用戶希望整合為分段式進度條
4. **維護困難**:複習邏輯散落在前端各處,調試和維護困難
### 解決方案
將複習會話狀態管理移至後端實現真正的後端驅動架構同時優化進度條UI設計。
## 技術架構設計
### 後端架構
#### 1. 數據模型設計
```csharp
// 學習會話實體
public class StudySession
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public DateTime StartedAt { get; set; }
public DateTime? CompletedAt { get; set; }
public SessionStatus Status { get; set; }
public List<StudyCard> Cards { get; set; } = new();
public int CurrentCardIndex { get; set; }
public string? CurrentTestType { get; set; }
public int TotalTests { get; set; }
public int CompletedTests { get; set; }
}
// 詞卡學習進度
public class StudyCard
{
public Guid Id { get; set; }
public Guid StudySessionId { get; set; }
public Guid FlashcardId { get; set; }
public string Word { get; set; } = string.Empty;
public List<string> PlannedTests { get; set; } = new();
public List<TestResult> CompletedTests { get; set; } = new();
public bool IsCompleted => CompletedTests.Count >= PlannedTests.Count;
public int Order { get; set; }
// 導航屬性
public StudySession StudySession { get; set; }
public Flashcard Flashcard { get; set; }
}
// 測驗結果實體
public class TestResult
{
public Guid Id { get; set; }
public Guid StudyCardId { get; set; }
public string TestType { get; set; } = string.Empty;
public bool IsCorrect { get; set; }
public string? UserAnswer { get; set; }
public int? ConfidenceLevel { get; set; } // 1-5, 用於翻卡記憶
public int ResponseTimeMs { get; set; }
public DateTime CompletedAt { get; set; }
// 導航屬性
public StudyCard StudyCard { get; set; }
}
// 會話狀態枚舉
public enum SessionStatus
{
Active, // 進行中
Completed, // 已完成
Paused, // 暫停
Abandoned // 放棄
}
```
#### 2. 服務層設計
```csharp
// 學習會話服務介面
public interface IStudySessionService
{
Task<StudySessionDto> StartSessionAsync(Guid userId);
Task<CurrentTestDto> GetCurrentTestAsync(Guid sessionId);
Task<SubmitTestResponseDto> SubmitTestAsync(Guid sessionId, SubmitTestRequestDto request);
Task<NextTestDto> GetNextTestAsync(Guid sessionId);
Task<ProgressDto> GetProgressAsync(Guid sessionId);
Task<CompleteSessionResponseDto> CompleteSessionAsync(Guid sessionId);
}
// 測驗模式選擇服務
public interface IReviewModeSelector
{
List<string> GetPlannedTests(string userCEFRLevel, string wordCEFRLevel);
string GetNextTestType(StudyCard card);
}
```
#### 3. API端點設計
```csharp
[Route("api/study/sessions")]
public class StudySessionController : ControllerBase
{
// 開始學習會話
[HttpPost("start")]
public async Task<ActionResult<StudySessionDto>> StartSession()
// 獲取當前測驗
[HttpGet("{sessionId}/current-test")]
public async Task<ActionResult<CurrentTestDto>> GetCurrentTest(Guid sessionId)
// 提交測驗結果
[HttpPost("{sessionId}/submit-test")]
public async Task<ActionResult<SubmitTestResponseDto>> SubmitTest(Guid sessionId, SubmitTestRequestDto request)
// 獲取下一個測驗
[HttpGet("{sessionId}/next-test")]
public async Task<ActionResult<NextTestDto>> GetNextTest(Guid sessionId)
// 獲取詳細進度
[HttpGet("{sessionId}/progress")]
public async Task<ActionResult<ProgressDto>> GetProgress(Guid sessionId)
// 結束會話
[HttpPut("{sessionId}/complete")]
public async Task<ActionResult<CompleteSessionResponseDto>> CompleteSession(Guid sessionId)
}
```
### 前端架構
#### 1. 服務層重構
```typescript
// 學習會話服務
class StudySessionService {
async startSession(): Promise<StudySessionResponse> {
return await this.post('/api/study/sessions/start');
}
async getCurrentTest(sessionId: string): Promise<CurrentTestResponse> {
return await this.get(`/api/study/sessions/${sessionId}/current-test`);
}
async submitTest(sessionId: string, result: TestResult): Promise<SubmitTestResponse> {
return await this.post(`/api/study/sessions/${sessionId}/submit-test`, result);
}
async getNextTest(sessionId: string): Promise<NextTestResponse> {
return await this.get(`/api/study/sessions/${sessionId}/next-test`);
}
async getProgress(sessionId: string): Promise<ProgressResponse> {
return await this.get(`/api/study/sessions/${sessionId}/progress`);
}
async completeSession(sessionId: string): Promise<CompleteSessionResponse> {
return await this.put(`/api/study/sessions/${sessionId}/complete`);
}
}
```
#### 2. React組件簡化
```typescript
// 簡化的 LearnPage 組件
function LearnPage() {
const [session, setSession] = useState<StudySession | null>(null);
const [currentTest, setCurrentTest] = useState<CurrentTest | null>(null);
const [progress, setProgress] = useState<Progress | null>(null);
// 簡化的狀態管理 - 只保留UI相關狀態
const [selectedAnswer, setSelectedAnswer] = useState<string | null>(null);
const [showResult, setShowResult] = useState(false);
const [isLoading, setIsLoading] = useState(false);
// 簡化的邏輯
const handleStartSession = async () => {
const newSession = await studyService.startSession();
setSession(newSession);
loadCurrentTest(newSession.id);
};
const handleSubmitAnswer = async (result: TestResult) => {
await studyService.submitTest(session.id, result);
loadNextTest();
};
}
```
## 實施計劃
### 階段一:後端擴展 (預計2-3天)
#### Day 1: 數據模型和遷移
- [ ] 創建 StudySession, StudyCard, TestResult 實體
- [ ] 創建資料庫遷移
- [ ] 更新 DbContext
#### Day 2: 服務層實現
- [ ] 實現 StudySessionService
- [ ] 實現 ReviewModeSelector
- [ ] 單元測試
#### Day 3: API控制器
- [ ] 實現 StudySessionController
- [ ] API集成測試
- [ ] Swagger文檔更新
### 階段二:前端重構 (預計2天)
#### Day 4: 服務層和狀態管理
- [ ] 創建 StudySessionService
- [ ] 重構 LearnPage 組件
- [ ] 移除複雜的本地狀態
#### Day 5: UI組件優化
- [ ] 簡化測驗組件
- [ ] 更新導航邏輯
- [ ] 錯誤處理優化
### 階段三:進度條美化 (預計1天)
#### Day 6: 分段式進度條
- [ ] 設計 SegmentedProgressBar 組件
- [ ] 實現詞卡分段顯示
- [ ] 添加hover tooltip功能
- [ ] 響應式布局優化
### 階段四:測試與部署 (預計1天)
#### Day 7: 完整測試
- [ ] 端到端學習流程測試
- [ ] 進度追蹤準確性驗證
- [ ] 性能測試
- [ ] 用戶體驗測試
## 數據流程圖
```mermaid
graph TD
A[用戶開始學習] --> B[POST /sessions/start]
B --> C[後端創建StudySession]
C --> D[後端規劃詞卡測驗]
D --> E[返回會話信息]
E --> F[GET /sessions/{id}/current-test]
F --> G[後端返回當前測驗]
G --> H[前端顯示測驗UI]
H --> I[用戶完成測驗]
I --> J[POST /sessions/{id}/submit-test]
J --> K[後端記錄結果]
K --> L{該詞卡測驗完成?}
L -->|否| M[GET /sessions/{id}/next-test]
L -->|是| N[後端計算SM2並更新]
N --> O{所有詞卡完成?}
O -->|否| M
O -->|是| P[PUT /sessions/{id}/complete]
M --> G
P --> Q[學習完成]
```
## 關鍵優勢
### 可靠性提升
- ✅ 數據一致性:狀態存在資料庫,不怕頁面刷新
- ✅ 錯誤恢復:會話可暫停和恢復
- ✅ 邏輯正確:只有完成所有測驗才標記詞卡完成
### 維護性改善
- ✅ 業務邏輯集中:複習邏輯在後端統一管理
- ✅ 前端簡化專注UI渲染和用戶互動
- ✅ 測試友好API可獨立測試
### 用戶體驗優化
- ✅ 進度條美化:分段式設計更直觀
- ✅ 響應速度:減少前端複雜計算
- ✅ 數據准確:實時同步學習進度
## 風險評估與應對
### 風險點
1. **數據遷移風險**:現有學習記錄可能需要轉換
2. **API性能風險**頻繁API調用可能影響響應速度
3. **向下兼容風險**:可能影響現有功能
### 應對措施
1. **分階段部署**:先在測試環境驗證,再逐步上線
2. **數據備份**:重構前完整備份現有數據
3. **性能監控**實施API性能監控和優化
4. **回滾方案**:保留舊版本代碼,必要時快速回滾
## 成功標準
### 功能標準
- [ ] 詞卡必須完成所有預定測驗才能標記為完成
- [ ] 學習進度準確追蹤和顯示
- [ ] 支持會話暫停和恢復
- [ ] 分段式進度條正確顯示詞卡分佈
### 性能標準
- [ ] API響應時間 < 500ms
- [ ] 頁面載入時間 < 2s
- [ ] 學習流程無明顯卡頓
### 用戶體驗標準
- [ ] 學習流程直觀流暢
- [ ] 進度顯示清晰準確
- [ ] 錯誤處理友好
---
**創建時間**: 2025-09-26
**負責人**: Claude Code
**預計完成**: 2025-10-03 (7個工作天)