feat: 添加重構計劃文檔和前端會話狀態優化
- 新增複習系統重構計劃文檔,詳細規劃後端驅動架構 - 優化前端學習頁面,添加詞卡複習會話狀態管理 - 實現測驗項目進度追蹤和任務清單彈出功能 - 清理過期文檔檔案 - 為後續重構奠定基礎 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
50cf813400
commit
6c8c656dc3
173
CEFR系統更新完成報告.md
173
CEFR系統更新完成報告.md
|
|
@ -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架構更加專業和標準!** 📚✅
|
|
||||||
|
|
@ -11,6 +11,26 @@ import MasteryIndicator from '@/components/review/MasteryIndicator'
|
||||||
import { flashcardsService, type Flashcard } from '@/lib/services/flashcards'
|
import { flashcardsService, type Flashcard } from '@/lib/services/flashcards'
|
||||||
import { calculateCurrentMastery, getReviewTypesByDifficulty } from '@/lib/utils/masteryCalculator'
|
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接口,包含智能複習需要的欄位
|
// 擴展的Flashcard接口,包含智能複習需要的欄位
|
||||||
interface ExtendedFlashcard extends Omit<Flashcard, 'nextReviewDate'> {
|
interface ExtendedFlashcard extends Omit<Flashcard, 'nextReviewDate'> {
|
||||||
nextReviewDate?: string; // 下次復習日期 (可選)
|
nextReviewDate?: string; // 下次復習日期 (可選)
|
||||||
|
|
@ -24,6 +44,26 @@ interface ExtendedFlashcard extends Omit<Flashcard, 'nextReviewDate'> {
|
||||||
// 注意:userLevel和wordLevel已移除,改用即時CEFR轉換
|
// 注意: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() {
|
export default function LearnPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [mounted, setMounted] = useState(false)
|
const [mounted, setMounted] = useState(false)
|
||||||
|
|
@ -49,6 +89,13 @@ export default function LearnPage() {
|
||||||
// 測驗進度狀態
|
// 測驗進度狀態
|
||||||
const [totalTests, setTotalTests] = useState(0) // 所有測驗總數
|
const [totalTests, setTotalTests] = useState(0) // 所有測驗總數
|
||||||
const [completedTests, setCompletedTests] = 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狀態
|
// UI狀態
|
||||||
const [modalImage, setModalImage] = useState<string | null>(null)
|
const [modalImage, setModalImage] = useState<string | null>(null)
|
||||||
|
|
@ -57,6 +104,7 @@ export default function LearnPage() {
|
||||||
const [reportingCard, setReportingCard] = useState<any>(null)
|
const [reportingCard, setReportingCard] = useState<any>(null)
|
||||||
const [showComplete, setShowComplete] = useState(false)
|
const [showComplete, setShowComplete] = useState(false)
|
||||||
const [showNoDueCards, setShowNoDueCards] = useState(false)
|
const [showNoDueCards, setShowNoDueCards] = useState(false)
|
||||||
|
const [showTaskListModal, setShowTaskListModal] = useState(false)
|
||||||
const [cardHeight, setCardHeight] = useState<number>(400)
|
const [cardHeight, setCardHeight] = useState<number>(400)
|
||||||
|
|
||||||
// 題型特定狀態
|
// 題型特定狀態
|
||||||
|
|
@ -144,16 +192,40 @@ export default function LearnPage() {
|
||||||
setCompletedTests(0);
|
setCompletedTests(0);
|
||||||
setDueCards(cardsToUse);
|
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];
|
const firstCard = cardsToUse[0];
|
||||||
setCurrentCard(firstCard);
|
setCurrentCard(firstCard);
|
||||||
setCurrentCardIndex(0);
|
setCurrentCardIndex(0);
|
||||||
|
|
||||||
|
// 開始第一張詞卡的複習會話
|
||||||
|
startCardReviewSession(firstCard);
|
||||||
|
|
||||||
// 系統自動選擇模式
|
// 系統自動選擇模式
|
||||||
const selectedMode = await selectOptimalReviewMode(firstCard);
|
const selectedMode = await selectOptimalReviewMode(firstCard);
|
||||||
setMode(selectedMode);
|
setMode(selectedMode);
|
||||||
setIsAutoSelecting(false);
|
setIsAutoSelecting(false);
|
||||||
|
|
||||||
|
// 標記第一個測驗項目為當前狀態
|
||||||
|
if (testItemsList.length > 0) {
|
||||||
|
setTestItems(prev =>
|
||||||
|
prev.map((item, index) =>
|
||||||
|
index === 0
|
||||||
|
? { ...item, isCurrent: true }
|
||||||
|
: item
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`🎯 初始載入: ${firstCard.word}, 選擇模式: ${selectedMode}`);
|
console.log(`🎯 初始載入: ${firstCard.word}, 選擇模式: ${selectedMode}`);
|
||||||
} else {
|
} else {
|
||||||
// 沒有到期詞卡
|
// 沒有到期詞卡
|
||||||
|
|
@ -396,6 +468,105 @@ export default function LearnPage() {
|
||||||
return labels[mode] || mode;
|
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 = () => {
|
const resetAllStates = () => {
|
||||||
setIsFlipped(false);
|
setIsFlipped(false);
|
||||||
|
|
@ -538,8 +709,8 @@ export default function LearnPage() {
|
||||||
total: prev.total + 1
|
total: prev.total + 1
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// 提交復習結果
|
// 記錄測驗結果
|
||||||
await submitReviewResult(isCorrect, userSentence);
|
recordTestResult(isCorrect, userSentence);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleResetReorder = () => {
|
const handleResetReorder = () => {
|
||||||
|
|
@ -556,15 +727,60 @@ export default function LearnPage() {
|
||||||
setIsFlipped(!isFlipped)
|
setIsFlipped(!isFlipped)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 移動到下一個測驗或下一張詞卡
|
||||||
const handleNext = async () => {
|
const handleNext = async () => {
|
||||||
if (currentCardIndex < dueCards.length - 1) {
|
if (!currentCard || !currentCardSession) return;
|
||||||
await loadNextCardWithAutoMode(currentCardIndex + 1);
|
|
||||||
|
// 檢查當前詞卡是否還有未完成的測驗
|
||||||
|
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 {
|
} else {
|
||||||
|
// 當前詞卡的所有測驗都已完成,移動到下一張詞卡
|
||||||
|
if (currentCardIndex < dueCards.length - 1) {
|
||||||
|
const nextCardIndex = currentCardIndex + 1;
|
||||||
|
const nextCard = dueCards[nextCardIndex];
|
||||||
|
|
||||||
|
// 開始新詞卡的複習會話
|
||||||
|
startCardReviewSession(nextCard);
|
||||||
|
|
||||||
|
await loadNextCardWithAutoMode(nextCardIndex);
|
||||||
|
console.log(`➡️ 移動到下一張詞卡: ${nextCard.word}`);
|
||||||
|
} else {
|
||||||
|
// 所有詞卡都已完成
|
||||||
setShowComplete(true);
|
setShowComplete(true);
|
||||||
|
console.log(`🎉 所有詞卡復習完成!`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePrevious = async () => {
|
const handlePrevious = async () => {
|
||||||
|
// 暫時保持簡單的向前導航
|
||||||
if (currentCardIndex > 0) {
|
if (currentCardIndex > 0) {
|
||||||
await loadNextCardWithAutoMode(currentCardIndex - 1);
|
await loadNextCardWithAutoMode(currentCardIndex - 1);
|
||||||
}
|
}
|
||||||
|
|
@ -582,46 +798,135 @@ export default function LearnPage() {
|
||||||
total: prev.total + 1
|
total: prev.total + 1
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// 提交復習結果到後端
|
// 記錄測驗結果到本地會話
|
||||||
await submitReviewResult(isCorrect, answer);
|
recordTestResult(isCorrect, answer);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提交復習結果並更新測驗進度
|
// 記錄測驗結果到本地會話(不提交到後端)
|
||||||
const submitReviewResult = async (isCorrect: boolean, userAnswer?: string, confidenceLevel?: number) => {
|
const recordTestResult = (isCorrect: boolean, userAnswer?: string, confidenceLevel?: number) => {
|
||||||
if (!currentCard) return;
|
if (!currentCard || !currentCardSession) return;
|
||||||
|
|
||||||
try {
|
const testResult: TestResult = {
|
||||||
const result = await flashcardsService.submitReview(currentCard.id, {
|
testType: mode,
|
||||||
isCorrect,
|
isCorrect,
|
||||||
confidenceLevel,
|
|
||||||
questionType: mode,
|
|
||||||
userAnswer,
|
userAnswer,
|
||||||
timeTaken: Date.now() - Date.now() // 簡化時間計算
|
confidenceLevel,
|
||||||
});
|
responseTimeMs: 2000, // 簡化時間計算,稍後可改進
|
||||||
|
completedAt: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
if (result.success && result.data) {
|
// 更新當前會話的測驗結果
|
||||||
console.log('復習結果提交成功:', result.data);
|
const updatedSession = {
|
||||||
// 更新卡片的熟悉度等資訊,但不觸發卡片重新載入
|
...currentCardSession,
|
||||||
setCurrentCard(prev => prev ? {
|
completedTests: [...currentCardSession.completedTests, testResult]
|
||||||
...prev,
|
};
|
||||||
masteryLevel: result.data!.masteryLevel,
|
|
||||||
nextReviewDate: result.data!.nextReviewDate
|
// 檢查是否完成所有預定測驗
|
||||||
} : null);
|
const isAllTestsCompleted = updatedSession.completedTests.length >= updatedSession.plannedTests.length;
|
||||||
} else {
|
if (isAllTestsCompleted) {
|
||||||
console.log('復習結果提交失敗,繼續運行');
|
updatedSession.isCompleted = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新測驗進度(無論提交成功或失敗)
|
setCurrentCardSession(updatedSession);
|
||||||
|
|
||||||
|
// 更新會話映射
|
||||||
|
setCardReviewSessions(prev => new Map(prev.set(currentCard.id, updatedSession)));
|
||||||
|
|
||||||
|
// 更新測驗進度
|
||||||
setCompletedTests(prev => {
|
setCompletedTests(prev => {
|
||||||
const newCompleted = prev + 1;
|
const newCompleted = prev + 1;
|
||||||
console.log(`📈 測驗進度更新: ${newCompleted}/${totalTests} (${Math.round((newCompleted/totalTests)*100)}%)`);
|
console.log(`📈 測驗進度更新: ${newCompleted}/${totalTests} (${Math.round((newCompleted/totalTests)*100)}%)`);
|
||||||
return newCompleted;
|
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 correctCount = session.completedTests.filter(t => t.isCorrect).length;
|
||||||
|
const totalTests = session.completedTests.length;
|
||||||
|
const accuracy = totalTests > 0 ? correctCount / totalTests : 0;
|
||||||
|
|
||||||
|
// 計算平均信心等級(用於翻卡記憶測驗)
|
||||||
|
const confidenceTests = session.completedTests.filter(t => t.confidenceLevel !== undefined);
|
||||||
|
const avgConfidence = confidenceTests.length > 0
|
||||||
|
? confidenceTests.reduce((sum, t) => sum + (t.confidenceLevel || 3), 0) / confidenceTests.length
|
||||||
|
: 3;
|
||||||
|
|
||||||
|
// 計算平均答題時間
|
||||||
|
const avgResponseTime = session.completedTests.reduce((sum, t) => sum + t.responseTimeMs, 0) / totalTests;
|
||||||
|
|
||||||
|
// 確定主要測驗類型(用於後端SM2算法)
|
||||||
|
const primaryTestType = session.completedTests[0]?.testType || 'flip-memory';
|
||||||
|
|
||||||
|
console.log(`🔥 提交詞卡完整復習結果:`, {
|
||||||
|
word: session.word,
|
||||||
|
accuracy: `${Math.round(accuracy * 100)}%`,
|
||||||
|
avgConfidence,
|
||||||
|
avgResponseTime: `${avgResponseTime}ms`,
|
||||||
|
primaryTestType,
|
||||||
|
completedTests: session.completedTests.length
|
||||||
|
});
|
||||||
|
|
||||||
|
// 提交到後端
|
||||||
|
const result = await flashcardsService.submitReview(session.cardId, {
|
||||||
|
isCorrect: accuracy >= 0.7, // 70%以上正確率視為通過
|
||||||
|
confidenceLevel: Math.round(avgConfidence),
|
||||||
|
questionType: primaryTestType,
|
||||||
|
userAnswer: `綜合${totalTests}個測驗,正確率${Math.round(accuracy * 100)}%`,
|
||||||
|
timeTaken: Math.round(avgResponseTime)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
console.log('✅ 詞卡復習結果提交成功:', result.data);
|
||||||
|
|
||||||
|
// 更新詞卡的熟悉度等資訊
|
||||||
|
if (currentCard && currentCard.id === session.cardId) {
|
||||||
|
setCurrentCard(prev => prev ? {
|
||||||
|
...prev,
|
||||||
|
masteryLevel: result.data!.masteryLevel,
|
||||||
|
nextReviewDate: result.data!.nextReviewDate
|
||||||
|
} : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 增加已完成詞卡數量
|
||||||
|
setCompletedCards(prev => prev + 1);
|
||||||
|
|
||||||
|
console.log(`🎉 詞卡 ${session.word} 復習完成!新熟悉度: ${result.data.masteryLevel}%, 下次復習: ${result.data.nextReviewDate}`);
|
||||||
|
} else {
|
||||||
|
console.error('詞卡復習結果提交失敗:', result.error);
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('提交復習結果失敗:', error);
|
console.error('完成詞卡復習時發生錯誤:', error);
|
||||||
// 即使出錯也更新進度,避免卡住
|
|
||||||
setCompletedTests(prev => prev + 1);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -636,8 +941,8 @@ export default function LearnPage() {
|
||||||
total: prev.total + 1
|
total: prev.total + 1
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// 提交復習結果
|
// 記錄測驗結果
|
||||||
await submitReviewResult(isCorrect, fillAnswer);
|
recordTestResult(isCorrect, fillAnswer);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleListeningAnswer = async (answer: string) => {
|
const handleListeningAnswer = async (answer: string) => {
|
||||||
|
|
@ -652,8 +957,8 @@ export default function LearnPage() {
|
||||||
total: prev.total + 1
|
total: prev.total + 1
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// 提交復習結果
|
// 記錄測驗結果
|
||||||
await submitReviewResult(isCorrect, answer);
|
recordTestResult(isCorrect, answer);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSpeakingAnswer = async (transcript: string) => {
|
const handleSpeakingAnswer = async (transcript: string) => {
|
||||||
|
|
@ -667,8 +972,8 @@ export default function LearnPage() {
|
||||||
total: prev.total + 1
|
total: prev.total + 1
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// 提交復習結果
|
// 記錄測驗結果
|
||||||
await submitReviewResult(isCorrect, transcript);
|
recordTestResult(isCorrect, transcript);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSentenceListeningAnswer = async (answer: string) => {
|
const handleSentenceListeningAnswer = async (answer: string) => {
|
||||||
|
|
@ -683,8 +988,8 @@ export default function LearnPage() {
|
||||||
total: prev.total + 1
|
total: prev.total + 1
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// 提交復習結果
|
// 記錄測驗結果
|
||||||
await submitReviewResult(isCorrect, answer);
|
recordTestResult(isCorrect, answer);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReportSubmit = () => {
|
const handleReportSubmit = () => {
|
||||||
|
|
@ -701,6 +1006,8 @@ export default function LearnPage() {
|
||||||
setScore({ correct: 0, total: 0 })
|
setScore({ correct: 0, total: 0 })
|
||||||
setCompletedTests(0)
|
setCompletedTests(0)
|
||||||
setTotalTests(0)
|
setTotalTests(0)
|
||||||
|
setTestItems([])
|
||||||
|
setCurrentTestItemIndex(0)
|
||||||
setShowComplete(false)
|
setShowComplete(false)
|
||||||
setShowNoDueCards(false)
|
setShowNoDueCards(false)
|
||||||
await loadDueCards(); // 重新載入到期詞卡
|
await loadDueCards(); // 重新載入到期詞卡
|
||||||
|
|
@ -787,172 +1094,77 @@ export default function LearnPage() {
|
||||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||||
{/* Progress Bar */}
|
{/* Progress Bar */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex justify-between items-center mb-2">
|
<div className="flex justify-between items-center mb-3">
|
||||||
<span className="text-sm text-gray-600">進度</span>
|
<span className="text-sm font-medium text-gray-900">學習進度</span>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-6">
|
||||||
<span className="text-sm text-gray-600">
|
{/* 詞卡進度 */}
|
||||||
{completedTests} / {totalTests} 測驗
|
<div className="text-sm text-gray-600">
|
||||||
</span>
|
<span className="font-medium">詞卡:</span>
|
||||||
<span className="text-xs text-gray-500">
|
<span className="ml-1 text-green-600 font-semibold">{completedCards}</span>
|
||||||
詞卡 {currentCardIndex + 1}/{dueCards.length}
|
|
||||||
</span>
|
|
||||||
<div className="text-sm">
|
|
||||||
<span className="text-green-600 font-semibold">{score.correct}</span>
|
|
||||||
<span className="text-gray-500">/</span>
|
<span className="text-gray-500">/</span>
|
||||||
<span className="text-gray-600">{score.total}</span>
|
<span className="text-gray-600">{dueCards.length}</span>
|
||||||
{score.total > 0 && (
|
{dueCards.length > 0 && (
|
||||||
<span className="text-blue-600 ml-2">
|
<span className="text-blue-600 ml-1">
|
||||||
({Math.round((score.correct / score.total) * 100)}%)
|
({Math.round((completedCards / dueCards.length) * 100)}%)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
|
||||||
|
{/* 雙層進度條 */}
|
||||||
|
<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
|
<div
|
||||||
className="bg-primary h-2 rounded-full transition-all"
|
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 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}%` }}
|
style={{ width: `${totalTests > 0 ? (completedTests / totalTests) * 100 : 0}%` }}
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<span className="text-xs text-gray-500 w-8">
|
||||||
|
{totalTests > 0 ? Math.round((completedTests / totalTests) * 100) : 0}%
|
||||||
{/* 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>
|
|
||||||
</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>
|
</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>
|
</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>
|
|
||||||
</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' ? (
|
{mode === 'flip-memory' ? (
|
||||||
/* Flip Card Mode */
|
/* Flip Card Mode */
|
||||||
|
|
@ -1885,6 +2097,111 @@ export default function LearnPage() {
|
||||||
</div>
|
</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 */}
|
{/* Complete Modal */}
|
||||||
{showComplete && (
|
{showComplete && (
|
||||||
<LearningComplete
|
<LearningComplete
|
||||||
|
|
@ -1942,6 +2259,7 @@ export default function LearnPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style jsx>{`
|
<style jsx>{`
|
||||||
|
|
|
||||||
179
冗餘數值欄位移除完成報告.md
179
冗餘數值欄位移除完成報告.md
|
|
@ -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字符串 + 即時轉換 (標準化)
|
|
||||||
```
|
|
||||||
|
|
||||||
**系統已準備投入生產使用,架構純淨、標準、高效!** 🚀📚
|
|
||||||
272
移除冗餘數值欄位計劃.md
272
移除冗餘數值欄位計劃.md
|
|
@ -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. 出現問題立即回滾,影響可控
|
|
||||||
|
|
||||||
**建議:開始實施此清理計劃,徹底解決資料重複問題!** 🎯
|
|
||||||
|
|
@ -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個工作天)
|
||||||
Loading…
Reference in New Issue