From 50a0a79d72ba3c57e5da26b488ece711728680d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=84=AD=E6=B2=9B=E8=BB=92?= Date: Sun, 28 Sep 2025 02:24:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E5=8B=95=E6=85=8B=E7=AD=94=E6=A1=88=E6=8E=A8=E5=B0=8E=E7=B3=BB?= =?UTF-8?q?=E7=B5=B1=E5=92=8CUI=E7=B5=84=E4=BB=B6=E5=84=AA=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 🎯 動態答案推導系統 ### 新增核心工具 - answerExtractor.ts: 從例句和挖空題目動態推導正確答案 - 支援單空格和多空格情況 - 完整的錯誤處理和降級機制 ### SentenceFillTest 組件升級 - 新增 filledQuestionText 屬性支援 - 實作 renderFilledSentence() 智能渲染 - 動態計算正確答案,無需資料庫存儲 - 改善確認答案按鈕:始終可見,智能狀態提示 ## 🎨 UI/UX 組件優化 ### 填空題交互改善 - 確認答案按鈕始終顯示 - 智能狀態文字:「請先輸入答案」→「確認答案」→「已確認」 - 動態答案驗證和音頻播放 ### 其他組件調整 - VocabChoiceTest: 優化音頻和發音顯示 - FlipMemoryTest: 改善例句區塊布局 - SentenceListeningTest: 優化結果顯示格式 - SentenceReorderTest: 調整音頻控制位置 ## 📊 系統優勢 ✅ **無需額外存儲**: 答案從現有資料動態推導 ✅ **資料一致性**: 答案永遠與例句匹配 ✅ **智能降級**: 後端無資料時自動使用前端邏輯 ✅ **用戶體驗**: 更清晰的操作指引和狀態回饋 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- Learn重命名為Review計劃.md | 171 ----- frontend/app/review-design/page.tsx | 3 + frontend/components/CardSelectionDialog.tsx | 2 +- .../review/review-tests/FlipMemoryTest.tsx | 4 +- .../review/review-tests/SentenceFillTest.tsx | 93 ++- .../review-tests/SentenceListeningTest.tsx | 7 +- .../review-tests/SentenceReorderTest.tsx | 5 +- .../review/review-tests/VocabChoiceTest.tsx | 10 - frontend/utils/answerExtractor.ts | 198 +++++ note/learn-backup/page-v1-original.tsx | 2 +- note/智能複習/詞彙學習-UI設計規範.md | 18 +- 前端架構說明-Learn功能.md | 686 ------------------ 12 files changed, 296 insertions(+), 903 deletions(-) delete mode 100644 Learn重命名為Review計劃.md create mode 100644 frontend/utils/answerExtractor.ts delete mode 100644 前端架構說明-Learn功能.md diff --git a/Learn重命名為Review計劃.md b/Learn重命名為Review計劃.md deleted file mode 100644 index dbe1ae8..0000000 --- a/Learn重命名為Review計劃.md +++ /dev/null @@ -1,171 +0,0 @@ -# Learn → Review 重命名計劃 - -**建立日期**: 2025-09-27 -**目的**: 將所有 learn 相關命名改為 review,使程式碼語義更清晰準確 - ---- - -## 📋 需要重命名的項目盤點 - -### **1. 目錄結構重命名** - -#### **主要目錄** -``` -FROM TO -/frontend/app/learn/ → /frontend/app/review/ -/frontend/components/learn/ → /frontend/components/review/ -/frontend/hooks/learn/ → /frontend/hooks/review/ -/frontend/lib/services/learn/ → /frontend/lib/services/review/ -``` - -#### **子目錄重命名** -``` -FROM TO -/components/learn/tests/ → /components/review/review-tests/ -``` - -### **2. 檔案重命名** - -#### **核心檔案** -``` -FROM TO -useLearnStore.ts → useReviewStore.ts -learnService.ts → reviewService.ts -TestRunner.tsx → ReviewRunner.tsx -``` - -#### **組件檔案 (可選重命名)** -``` -FROM TO -ProgressTracker.tsx → ReviewProgressTracker.tsx -TaskListModal.tsx → ReviewTaskListModal.tsx -LoadingStates.tsx → ReviewLoadingStates.tsx -``` - -### **3. 程式碼內容修改** - -#### **Import 路徑修改** -需要更新以下檔案中的import: -- `/app/learn/page.tsx` (8個import) -- `/components/learn/TestRunner.tsx` (2個import) -- `/lib/services/learn/learnService.ts` (1個import) - -```typescript -// FROM -import { ProgressTracker } from '@/components/learn/ProgressTracker' -import { useLearnStore } from '@/store/useLearnStore' -import { LearnService } from '@/lib/services/learn/learnService' - -// TO -import { ReviewProgressTracker } from '@/components/review/ReviewProgressTracker' -import { useReviewStore } from '@/store/useReviewStore' -import { ReviewService } from '@/lib/services/review/reviewService' -``` - -#### **類別和介面重命名** -```typescript -// FROM → TO -useLearnStore → useReviewStore -LearnState → ReviewState -LearnService → ReviewService -TestRunner → ReviewRunner - -// 函數名稱 -initializeLearnSession → initializeReviewSession -loadDueCards → loadDueReviewCards (可選) -``` - -#### **註解和文字更新** -所有註解中的 "learn" 改為 "review": -```typescript -// FROM -// 學習會話狀態 -// 載入到期詞卡 -// 初始化學習會話 - -// TO -// 複習會話狀態 -// 載入到期複習詞卡 -// 初始化複習會話 -``` - -### **4. 特殊考量** - -#### **URL路由保持不變** -- **用戶訪問**: 仍然是 `http://localhost:3000/learn` -- **檔案路徑**: `/app/review/page.tsx` (內部重命名) -- **Next.js**: 需要考慮路由映射問題 - -#### **外部引用檢查** -需要檢查是否有其他檔案引用了learn相關組件: -- Navigation.tsx 中的 learn 連結 -- Dashboard.tsx 中的 learn 按鈕 -- 其他頁面的跳轉邏輯 - ---- - -## 🚀 執行順序 - -### **階段一:目錄和檔案重命名** (15分鐘) -1. 重命名主要目錄結構 -2. 重命名核心檔案 -3. 更新檔案的export名稱 - -### **階段二:程式碼內容更新** (20分鐘) -1. 更新所有import路徑 -2. 重命名類別和介面 -3. 更新函數和變數名稱 -4. 更新註解和字串 - -### **階段三:路由配置** (10分鐘) -1. 確認Next.js路由正常運作 -2. 檢查外部引用是否正確 -3. 測試頁面是否正常載入 - -### **階段四:測試和驗證** (15分鐘) -1. 檢查編譯是否通過 -2. 測試功能是否正常 -3. 確認沒有遺漏的引用 - ---- - -## ⚠️ 風險評估 - -### **低風險項目** -- 內部檔案重命名 -- 註解和字串更新 -- 組件內部邏輯 - -### **中風險項目** -- Import路徑更新 (可能遺漏) -- Store狀態管理 (需要仔細測試) - -### **注意事項** -- URL路由 `/learn` 保持不變 -- 確保所有依賴關係正確更新 -- 備份重要檔案以防萬一 - ---- - -## 📝 驗證清單 - -### **重命名完成檢查** -- [ ] 所有目錄重命名完成 -- [ ] 所有檔案重命名完成 -- [ ] 所有import路徑更新 -- [ ] 所有類別名稱更新 -- [ ] 所有函數名稱更新 - -### **功能測試** -- [ ] 頁面可以正常載入 -- [ ] 測驗組件正常顯示 -- [ ] 狀態管理正常運作 -- [ ] API調用正常 -- [ ] 錯誤處理正常 - -### **外部整合測試** -- [ ] Navigation導航正常 -- [ ] Dashboard跳轉正常 -- [ ] 路由映射正確 - -這個重命名將讓程式碼語義更清晰,`review`(複習) 比 `learn`(學習) 更精確地描述這個功能的本質。 \ No newline at end of file diff --git a/frontend/app/review-design/page.tsx b/frontend/app/review-design/page.tsx index dcc4b73..c45b7d3 100644 --- a/frontend/app/review-design/page.tsx +++ b/frontend/app/review-design/page.tsx @@ -44,6 +44,7 @@ export default function ReviewTestsPage() { word: currentCard.word, definition: currentCard.definition, example: currentCard.example, + filledQuestionText: currentCard.filledQuestionText, exampleTranslation: currentCard.exampleTranslation, pronunciation: currentCard.pronunciation, difficultyLevel: currentCard.difficultyLevel, @@ -56,6 +57,7 @@ export default function ReviewTestsPage() { word: "loading...", definition: "Loading...", example: "Loading...", + filledQuestionText: undefined, exampleTranslation: "載入中...", pronunciation: "", difficultyLevel: "A1", @@ -188,6 +190,7 @@ export default function ReviewTestsPage() { word={mockCardData.word} definition={mockCardData.definition} example={mockCardData.example} + filledQuestionText={mockCardData.filledQuestionText} exampleTranslation={mockCardData.exampleTranslation} pronunciation={mockCardData.pronunciation} difficultyLevel={mockCardData.difficultyLevel} diff --git a/frontend/components/CardSelectionDialog.tsx b/frontend/components/CardSelectionDialog.tsx index cf202a4..bff8de6 100644 --- a/frontend/components/CardSelectionDialog.tsx +++ b/frontend/components/CardSelectionDialog.tsx @@ -241,7 +241,7 @@ const CardPreviewItem: React.FC = ({ {card.example && (
例句: -

"{card.example}"

+

{card.example}

{card.exampleTranslation && (

{card.exampleTranslation}

)} diff --git a/frontend/components/review/review-tests/FlipMemoryTest.tsx b/frontend/components/review/review-tests/FlipMemoryTest.tsx index 3a2a0f0..3a4e626 100644 --- a/frontend/components/review/review-tests/FlipMemoryTest.tsx +++ b/frontend/components/review/review-tests/FlipMemoryTest.tsx @@ -158,12 +158,12 @@ export const FlipMemoryTest: React.FC = ({

例句

-

"{example}"

+

{example}

e.stopPropagation()}>
-

"{exampleTranslation}"

+

{exampleTranslation}

{/* 同義詞區塊 */} diff --git a/frontend/components/review/review-tests/SentenceFillTest.tsx b/frontend/components/review/review-tests/SentenceFillTest.tsx index d212616..dbc0dc1 100644 --- a/frontend/components/review/review-tests/SentenceFillTest.tsx +++ b/frontend/components/review/review-tests/SentenceFillTest.tsx @@ -1,10 +1,12 @@ -import { useState } from 'react' +import { useState, useMemo } from 'react' import AudioPlayer from '@/components/AudioPlayer' +import { getCorrectAnswer } from '@/utils/answerExtractor' interface SentenceFillTestProps { word: string definition: string example: string + filledQuestionText?: string exampleTranslation: string pronunciation?: string difficultyLevel: string @@ -19,6 +21,7 @@ export const SentenceFillTest: React.FC = ({ word, definition, example, + filledQuestionText, exampleTranslation, pronunciation, difficultyLevel, @@ -44,10 +47,62 @@ export const SentenceFillTest: React.FC = ({ } } - const isCorrect = fillAnswer.toLowerCase().trim() === word.toLowerCase() - const targetWordLength = word.length + // 🆕 動態計算正確答案:從例句和挖空題目推導 + const correctAnswer = useMemo(() => { + return getCorrectAnswer(example, filledQuestionText, word); + }, [example, filledQuestionText, word]); + + const isCorrect = fillAnswer.toLowerCase().trim() === correctAnswer.toLowerCase().trim() + const targetWordLength = correctAnswer.length const inputWidth = Math.max(100, Math.max(targetWordLength * 12, fillAnswer.length * 12 + 20)) + // 🆕 智能填空渲染:優先使用後端提供的挖空題目 + const renderFilledSentence = () => { + if (!filledQuestionText) { + // 降級處理:使用原有的前端挖空邏輯 + return renderSentenceWithInput(); + } + + // 使用後端提供的挖空題目 + const parts = filledQuestionText.split('____'); + + return ( +
+ {parts.map((part, index) => ( + + {part} + {index < parts.length - 1 && ( + + setFillAnswer(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="" + disabled={disabled || showResult} + className={`inline-block px-2 py-1 text-center bg-transparent focus:outline-none disabled:bg-gray-100 ${ + fillAnswer + ? 'border-b-2 border-blue-500' + : 'border-b-2 border-dashed border-gray-400 focus:border-blue-400 focus:border-solid' + }`} + style={{ width: `${inputWidth}px` }} + /> + {!fillAnswer && ( + + ____ + + )} + + )} + + ))} +
+ ); + } + // 將例句中的目標詞替換為輸入框 const renderSentenceWithInput = () => { const parts = example.split(new RegExp(`\\b${word}\\b`, 'gi')) @@ -133,20 +188,23 @@ export const SentenceFillTest: React.FC = ({ {/* 填空句子區域 */}
- {renderSentenceWithInput()} + {renderFilledSentence()}
{/* 操作按鈕 */}
- {!showResult && fillAnswer.trim() && ( - - )} +
)} diff --git a/frontend/utils/answerExtractor.ts b/frontend/utils/answerExtractor.ts new file mode 100644 index 0000000..d817f5a --- /dev/null +++ b/frontend/utils/answerExtractor.ts @@ -0,0 +1,198 @@ +/** + * 答案推導工具 - 從例句和挖空例句中動態推導正確答案 + */ + +export interface AnswerExtractionResult { + answers: string[]; + isValid: boolean; + error?: string; +} + +/** + * 從例句和挖空題目中提取答案 + * @param example 原始例句 + * @param filledQuestion 挖空後的題目 + * @returns 提取的答案陣列 + */ +export function extractAnswerFromBlanks(example: string, filledQuestion: string): AnswerExtractionResult { + try { + // 輸入驗證 + if (!example || !filledQuestion) { + return { + answers: [], + isValid: false, + error: "例句或挖空題目為空" + }; + } + + if (!filledQuestion.includes('____')) { + return { + answers: [], + isValid: false, + error: "挖空題目中沒有找到 ____" + }; + } + + // 方法1: 正則匹配法 (推薦用於單個空格) + if (filledQuestion.split('____').length === 2) { + return extractSingleBlankAnswer(example, filledQuestion); + } + + // 方法2: 差異比對法 (用於多個空格) + return extractMultipleBlanksAnswers(example, filledQuestion); + + } catch (error) { + return { + answers: [], + isValid: false, + error: `答案提取失敗: ${error instanceof Error ? error.message : '未知錯誤'}` + }; + } +} + +/** + * 提取單個空格的答案 (正則匹配法) + */ +function extractSingleBlankAnswer(example: string, filledQuestion: string): AnswerExtractionResult { + try { + // 轉義特殊字符並替換 ____ 為捕獲群組 + const escapedPattern = filledQuestion + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // 轉義正則特殊字符 + .replace(/____/g, '(.+?)'); // 替換為非貪婪捕獲群組 + + const regex = new RegExp(`^${escapedPattern}$`, 'i'); + const match = example.match(regex); + + if (match && match[1]) { + const answer = match[1].trim(); + return { + answers: [answer], + isValid: true + }; + } + + // 如果完全匹配失敗,嘗試部分匹配 + const partialRegex = new RegExp(escapedPattern, 'i'); + const partialMatch = example.match(partialRegex); + + if (partialMatch && partialMatch[1]) { + const answer = partialMatch[1].trim(); + return { + answers: [answer], + isValid: true + }; + } + + return { + answers: [], + isValid: false, + error: "無法匹配例句和挖空題目" + }; + + } catch (error) { + return { + answers: [], + isValid: false, + error: `正則匹配失敗: ${error instanceof Error ? error.message : '未知錯誤'}` + }; + } +} + +/** + * 提取多個空格的答案 (差異比對法) + */ +function extractMultipleBlanksAnswers(example: string, filledQuestion: string): AnswerExtractionResult { + try { + const parts = filledQuestion.split('____'); + const answers: string[] = []; + + let currentPos = 0; + + for (let i = 0; i < parts.length - 1; i++) { + const beforePart = parts[i]; + const afterPart = parts[i + 1]; + + // 找到前半部分的結束位置 + const startPos = currentPos + beforePart.length; + + // 找到後半部分的開始位置 + let endPos: number; + if (afterPart === '') { + // 如果是最後一個空格,到句子結尾 + endPos = example.length; + } else { + endPos = example.indexOf(afterPart, startPos); + if (endPos === -1) { + return { + answers: [], + isValid: false, + error: `無法找到後半部分: "${afterPart}"` + }; + } + } + + // 提取中間的詞作為答案 + const answer = example.substring(startPos, endPos).trim(); + answers.push(answer); + + currentPos = endPos; + } + + return { + answers, + isValid: answers.length > 0 && answers.every(ans => ans.length > 0) + }; + + } catch (error) { + return { + answers: [], + isValid: false, + error: `多空格提取失敗: ${error instanceof Error ? error.message : '未知錯誤'}` + }; + } +} + +/** + * 獲取填空題的第一個答案 (最常用) + * @param example 原始例句 + * @param filledQuestion 挖空後的題目 + * @param fallbackAnswer 降級答案 (通常是 word 屬性) + * @returns 正確答案字串 + */ +export function getCorrectAnswer( + example: string, + filledQuestion: string | undefined, + fallbackAnswer: string +): string { + if (!filledQuestion) { + return fallbackAnswer; + } + + const result = extractAnswerFromBlanks(example, filledQuestion); + + if (result.isValid && result.answers.length > 0) { + return result.answers[0]; + } + + // 推導失敗時使用降級答案 + console.warn('答案推導失敗,使用降級答案:', result.error); + return fallbackAnswer; +} + +/** + * 驗證用戶答案是否正確 + * @param userAnswer 用戶輸入的答案 + * @param example 原始例句 + * @param filledQuestion 挖空後的題目 + * @param fallbackAnswer 降級答案 + * @returns 是否正確 + */ +export function validateAnswer( + userAnswer: string, + example: string, + filledQuestion: string | undefined, + fallbackAnswer: string +): boolean { + const correctAnswer = getCorrectAnswer(example, filledQuestion, fallbackAnswer); + return userAnswer.toLowerCase().trim() === correctAnswer.toLowerCase().trim(); +} \ No newline at end of file diff --git a/note/learn-backup/page-v1-original.tsx b/note/learn-backup/page-v1-original.tsx index daa1939..93013cb 100644 --- a/note/learn-backup/page-v1-original.tsx +++ b/note/learn-backup/page-v1-original.tsx @@ -2106,7 +2106,7 @@ export default function LearnPage() {

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

diff --git a/note/智能複習/詞彙學習-UI設計規範.md b/note/智能複習/詞彙學習-UI設計規範.md index 884907a..3e1acbd 100644 --- a/note/智能複習/詞彙學習-UI設計規範.md +++ b/note/智能複習/詞彙學習-UI設計規範.md @@ -129,12 +129,12 @@

例句

-

"{example}"

+

{example}

-

"{exampleTranslation}"

+

{exampleTranslation}

{/* 同義詞區塊 */} @@ -295,7 +295,6 @@
- 發音: {pronunciation}
@@ -303,10 +302,10 @@

- 例句:"{example}" + 例句:{example}

- 翻譯:"{exampleTranslation}" + 翻譯:{exampleTranslation}

@@ -600,7 +599,7 @@ {!reorderResult && (

- 正確答案是:"{correctSentence}" + 正確答案是:{correctSentence}

)} @@ -608,14 +607,13 @@
- 發音:

- 中文翻譯:{translation} + {translation}

@@ -771,10 +769,10 @@

- 正確例句:"{correctSentence}" + {correctSentence}

- 中文翻譯:"{translation}" + {translation}

diff --git a/前端架構說明-Learn功能.md b/前端架構說明-Learn功能.md deleted file mode 100644 index 7c07375..0000000 --- a/前端架構說明-Learn功能.md +++ /dev/null @@ -1,686 +0,0 @@ -# 前端架構說明 - Learn功能 - -**建立日期**: 2025-09-27 -**目標**: 說明Learn功能的前端架構設計和運作機制 -**架構類型**: 企業級分層架構 + Zustand狀態管理 - ---- - -## 🏗️ 整體架構概覽 - -### **分層設計原則** -Learn功能採用**4層分離架構**,確保關注點分離和高可維護性: - -``` -┌─────────────────────────────────────────┐ -│ UI層 (Presentation) │ -│ /app/learn/page.tsx │ -│ 215行 - 純路由和渲染邏輯 │ -└─────────────────┬───────────────────────┘ - │ -┌─────────────────▼───────────────────────┐ -│ 組件層 (Components) │ -│ /components/learn/ │ -│ 獨立、可復用的UI組件 │ -└─────────────────┬───────────────────────┘ - │ -┌─────────────────▼───────────────────────┐ -│ 狀態層 (State Management) │ -│ /store/ - Zustand │ -│ 集中化狀態管理 │ -└─────────────────┬───────────────────────┘ - │ -┌─────────────────▼───────────────────────┐ -│ 服務層 (Services & API) │ -│ /lib/services/ + /lib/errors/ │ -│ API調用、錯誤處理、業務邏輯 │ -└─────────────────────────────────────────┘ -``` - ---- - -## 📱 UI層:純渲染邏輯 - -### **檔案**: `/app/learn/page.tsx` (215行) - -#### **職責** -- **路由管理** - Next.js頁面路由 -- **組件組合** - 組裝各個功能組件 -- **狀態訂閱** - 連接Zustand狀態 -- **事件分派** - 分派用戶操作到對應的store - -#### **核心代碼結構** -```typescript -export default function LearnPage() { - const router = useRouter() - - // 連接狀態管理 - const { - mounted, isLoading, currentCard, dueCards, - testItems, completedTests, totalTests, score, - showComplete, showNoDueCards, - setMounted, loadDueCards, initializeTestQueue, resetSession - } = useLearnStore() - - const { - showTaskListModal, showReportModal, modalImage, - setShowTaskListModal, closeReportModal, closeImageModal - } = useUIStore() - - // 初始化邏輯 - useEffect(() => { - setMounted(true) - initializeSession() - }, []) - - // 組件組合和渲染 - return ( -
- -
- - - - {showComplete && } - {modalImage && } - ... -
-
- ) -} -``` - -#### **設計特點** -- ✅ **無業務邏輯** - 只負責渲染和事件分派 -- ✅ **狀態訂閱** - 通過Zustand響應狀態變化 -- ✅ **組件組合** - 組裝功能組件,不包含具體實作 - ---- - -## 🧩 組件層:功能模組化 - -### **目錄結構** -``` -/components/learn/ -├── TestRunner.tsx # 🎯 測驗執行核心 -├── ProgressTracker.tsx # 📊 進度追蹤器 -├── TaskListModal.tsx # 📋 任務清單彈窗 -├── LoadingStates.tsx # ⏳ 載入狀態管理 -└── tests/ # 🎮 測驗類型組件庫 - ├── FlipMemoryTest.tsx # 翻卡記憶 - ├── VocabChoiceTest.tsx # 詞彙選擇 - ├── SentenceFillTest.tsx # 例句填空 - ├── SentenceReorderTest.tsx # 例句重組 - ├── VocabListeningTest.tsx # 詞彙聽力 - ├── SentenceListeningTest.tsx # 例句聽力 - ├── SentenceSpeakingTest.tsx # 例句口說 - └── index.ts # 統一匯出 -``` - -### **核心組件:TestRunner.tsx** - -#### **職責** -- **測驗路由** - 根據currentMode渲染對應測驗組件 -- **答案驗證** - 統一的答案檢查邏輯 -- **選項生成** - 為不同測驗類型生成選項 -- **狀態橋接** - 連接store和測驗組件 - -#### **運作流程** -```typescript -// 1. 從store獲取當前狀態 -const { currentCard, currentMode, updateScore, recordTestResult } = useLearnStore() - -// 2. 處理答題 -const handleAnswer = async (answer: string, confidenceLevel?: number) => { - const isCorrect = checkAnswer(answer, currentCard, currentMode) - updateScore(isCorrect) - await recordTestResult(isCorrect, answer, confidenceLevel) -} - -// 3. 根據模式渲染組件 -switch (currentMode) { - case 'flip-memory': - return - case 'vocab-choice': - return - // ... 其他測驗類型 -} -``` - -### **測驗組件設計模式** - -#### **統一接口設計** -所有測驗組件都遵循相同的Props接口: -```typescript -interface BaseTestProps { - // 詞卡基本資訊 - word: string - definition: string - example: string - exampleTranslation: string - pronunciation?: string - difficultyLevel: string - - // 事件處理 - onAnswer: (answer: string) => void - onReportError: () => void - onImageClick?: (image: string) => void - - // 狀態控制 - disabled?: boolean - - // 測驗特定選項 - options?: string[] // 選擇題用 - synonyms?: string[] // 翻卡用 - exampleImage?: string # 圖片相關測驗用 -} -``` - -#### **獨立狀態管理** -每個測驗組件管理自己的內部UI狀態: -```typescript -// 例:VocabChoiceTest.tsx -const [selectedAnswer, setSelectedAnswer] = useState(null) -const [showResult, setShowResult] = useState(false) - -// 例:SentenceReorderTest.tsx -const [shuffledWords, setShuffledWords] = useState([]) -const [arrangedWords, setArrangedWords] = useState([]) -``` - ---- - -## 🗄️ 狀態層:Zustand集中管理 - -### **狀態商店架構** - -#### **1. useLearnStore.ts** - 核心學習狀態 -```typescript -interface LearnState { - // 基本狀態 - mounted: boolean - isLoading: boolean - currentCard: ExtendedFlashcard | null - dueCards: ExtendedFlashcard[] - - // 測驗狀態 - currentMode: ReviewMode - testItems: TestItem[] - currentTestIndex: number - completedTests: number - totalTests: number - - // 進度統計 - score: { correct: number; total: number } - - // 流程控制 - showComplete: boolean - showNoDueCards: boolean - error: string | null - - // Actions - loadDueCards: () => Promise - initializeTestQueue: (completedTests: any[]) => void - recordTestResult: (isCorrect: boolean, ...) => Promise - goToNextTest: () => void - skipCurrentTest: () => void - resetSession: () => void -} -``` - -#### **2. useUIStore.ts** - UI控制狀態 -```typescript -interface UIState { - // Modal狀態 - showTaskListModal: boolean - showReportModal: boolean - modalImage: string | null - - // 錯誤回報 - reportReason: string - reportingCard: any | null - - // 便利方法 - openReportModal: (card: any) => void - closeReportModal: () => void - openImageModal: (image: string) => void - closeImageModal: () => void -} -``` - -### **狀態流轉機制** - -#### **學習會話初始化流程** -``` -1. setMounted(true) - ↓ -2. loadDueCards() → API: GET /api/flashcards/due - ↓ -3. loadCompletedTests() → API: GET /api/study/completed-tests - ↓ -4. initializeTestQueue() → 計算剩餘測驗,生成TestItem[] - ↓ -5. 設置currentCard和currentMode → 開始第一個測驗 -``` - -#### **測驗執行流程** -``` -1. 用戶答題 → TestComponent.onAnswer() - ↓ -2. TestRunner.handleAnswer() → 驗證答案正確性 - ↓ -3. updateScore() → 更新本地分數 - ↓ -4. recordTestResult() → API: POST /api/study/record-test - ↓ -5. goToNextTest() → 更新testItems,載入下一個測驗 -``` - ---- - -## 🔧 服務層:業務邏輯封裝 - -### **檔案結構** -``` -/lib/services/learn/ -└── learnService.ts # 學習API服務 - -/lib/errors/ -└── errorHandler.ts # 錯誤處理中心 - -/lib/utils/ -└── cefrUtils.ts # CEFR工具函數 -``` - -### **LearnService - API服務封裝** - -#### **核心方法** -```typescript -export class LearnService { - // 載入到期詞卡 - static async loadDueCards(limit = 50): Promise - - // 載入已完成測驗 (智能狀態恢復) - static async loadCompletedTests(cardIds: string[]): Promise - - // 記錄測驗結果 - static async recordTestResult(params: {...}): Promise - - // 生成測驗選項 - static async generateTestOptions(cardId: string, testType: string): Promise - - // 驗證學習會話完整性 - static validateSession(cards: ExtendedFlashcard[], testItems: TestItem[]): { - isValid: boolean - errors: string[] - } - - // 計算學習統計 - static calculateStats(testItems: TestItem[], score: {correct: number, total: number}): { - completed: number - total: number - progressPercentage: number - accuracyPercentage: number - estimatedTimeRemaining: number - } -} -``` - -### **ErrorHandler - 錯誤處理中心** - -#### **錯誤分類體系** -```typescript -export enum ErrorType { - NETWORK_ERROR = 'NETWORK_ERROR', // 網路連線問題 - API_ERROR = 'API_ERROR', // API伺服器錯誤 - VALIDATION_ERROR = 'VALIDATION_ERROR', // 輸入驗證錯誤 - AUTHENTICATION_ERROR = 'AUTHENTICATION_ERROR', // 認證失效 - UNKNOWN_ERROR = 'UNKNOWN_ERROR' // 未知錯誤 -} -``` - -#### **自動重試機制** -```typescript -// 帶重試的API調用 -const result = await RetryHandler.withRetry( - () => flashcardsService.getDueFlashcards(50), - 'loadDueCards', - 3 // 最多重試3次 -) -``` - -#### **降級處理** -```typescript -// 網路失敗時的降級策略 -if (FallbackService.shouldUseFallback(errorCount, networkStatus)) { - const emergencyCards = FallbackService.getEmergencyFlashcards() - // 使用緊急資料繼續學習 -} -``` - ---- - -## 🔄 資料流程詳細說明 - -### **1. 學習會話啟動 (Session Initialization)** - -#### **步驟1: 頁面載入** -```typescript -// /app/learn/page.tsx -useEffect(() => { - setMounted(true) // 標記組件已掛載 - initializeSession() // 開始初始化流程 -}, []) -``` - -#### **步驟2: 載入到期詞卡** -```typescript -// useLearnStore.ts - loadDueCards() -const apiResult = await flashcardsService.getDueFlashcards(50) -if (apiResult.success) { - set({ - dueCards: apiResult.data, - currentCard: apiResult.data[0], - currentCardIndex: 0 - }) -} -``` - -#### **步驟3: 智能狀態恢復** -```typescript -// 查詢已完成的測驗 (核心功能) -const completedTests = await LearnService.loadCompletedTests(cardIds) -// → API: GET /api/study/completed-tests?cardIds=["id1","id2",...] - -// 返回格式: -[ - { flashcardId: "id1", testType: "flip-memory", isCorrect: true }, - { flashcardId: "id1", testType: "vocab-choice", isCorrect: true }, - { flashcardId: "id2", testType: "flip-memory", isCorrect: false } -] -``` - -#### **步驟4: 測驗隊列生成** -```typescript -// useLearnStore.ts - initializeTestQueue() -dueCards.forEach(card => { - const userCEFRLevel = localStorage.getItem('userEnglishLevel') || 'A2' - const wordCEFRLevel = card.difficultyLevel || 'A2' - - // CEFR智能適配:決定測驗類型 - const allTestTypes = getReviewTypesByCEFR(userCEFRLevel, wordCEFRLevel) - - // 過濾已完成的測驗 - const completedTestTypes = completedTests - .filter(ct => ct.flashcardId === card.id) - .map(ct => ct.testType) - - const remainingTestTypes = allTestTypes.filter(testType => - !completedTestTypes.includes(testType) - ) - - // 生成TestItem[] - remainingTestTypes.forEach(testType => { - remainingTestItems.push({ - id: `${card.id}-${testType}`, - cardId: card.id, - word: card.word, - testType: testType as ReviewMode, - testName: getTestTypeName(testType), - isCompleted: false, - isCurrent: false, - order - }) - }) -}) -``` - -### **2. 測驗執行流程 (Test Execution)** - -#### **步驟1: 測驗渲染** -```typescript -// TestRunner.tsx - 根據currentMode選擇組件 -switch (currentMode) { - case 'flip-memory': - return - case 'vocab-choice': - return - // ... -} -``` - -#### **步驟2: 用戶互動** -```typescript -// 例:VocabChoiceTest.tsx -const handleAnswerSelect = (answer: string) => { - setSelectedAnswer(answer) // 本地UI狀態 - setShowResult(true) // 顯示結果 - onAnswer(answer) // 回調到TestRunner -} -``` - -#### **步驟3: 答案處理** -```typescript -// TestRunner.tsx - handleAnswer() -const isCorrect = checkAnswer(answer, currentCard, currentMode) -updateScore(isCorrect) // 更新分數 (本地) -await recordTestResult(isCorrect, answer, confidenceLevel) // 記錄到後端 -``` - -#### **步驟4: 狀態更新和下一題** -```typescript -// useLearnStore.ts - recordTestResult() -if (result.success) { - // 更新測驗完成狀態 - const updatedTestItems = testItems.map((item, index) => - index === currentTestIndex - ? { ...item, isCompleted: true, isCurrent: false } - : item - ) - - set({ - testItems: updatedTestItems, - completedTests: get().completedTests + 1 - }) - - // 延遲進入下一個測驗 - setTimeout(() => { - get().goToNextTest() - }, 1500) -} -``` - -### **3. 智能導航系統 (Smart Navigation)** - -#### **下一題邏輯** -```typescript -// useLearnStore.ts - goToNextTest() -if (currentTestIndex + 1 < testItems.length) { - const nextIndex = currentTestIndex + 1 - const nextTestItem = testItems[nextIndex] - const nextCard = dueCards.find(c => c.id === nextTestItem.cardId) - - set({ - currentTestIndex: nextIndex, - currentMode: nextTestItem.testType, - currentCard: nextCard - }) -} else { - set({ showComplete: true }) // 所有測驗完成 -} -``` - -#### **跳過測驗邏輯** -```typescript -// useLearnStore.ts - skipCurrentTest() -const currentTest = testItems[currentTestIndex] - -// 將當前測驗移到隊列最後 -const newItems = [...testItems] -newItems.splice(currentTestIndex, 1) // 移除當前 -newItems.push({ ...currentTest, isCurrent: false }) // 添加到最後 - -// 標記新的當前項目 -if (newItems[currentTestIndex]) { - newItems[currentTestIndex].isCurrent = true -} - -set({ testItems: newItems }) -``` - ---- - -## 🛡️ 錯誤處理架構 - -### **3層錯誤防護** - -#### **第1層:組件層錯誤邊界** -```typescript -// 每個測驗組件內建錯誤處理 -if (disabled || showResult) return // 防止重複操作 -if (!currentCard) return // 防止空值錯誤 -``` - -#### **第2層:服務層重試機制** -```typescript -// API調用自動重試 -await RetryHandler.withRetry( - () => flashcardsService.recordTestCompletion(params), - 'recordTestResult', - 3 -) -``` - -#### **第3層:降級和備份** -```typescript -// 網路失敗時的本地備份 -FallbackService.saveProgressToLocal({ - currentCardId: currentCard.id, - completedTests: testItems.filter(t => t.isCompleted), - score -}) -``` - -### **錯誤恢復流程** -``` -1. 網路錯誤 → 自動重試3次 -2. 重試失敗 → 顯示錯誤訊息,啟用本地模式 -3. 本地模式 → 使用緊急資料,本地儲存進度 -4. 網路恢復 → 同步本地進度到伺服器 -``` - ---- - -## 🎯 CEFR智能適配機制 - -### **四情境智能判斷** -```typescript -// /lib/utils/cefrUtils.ts -export const getReviewTypesByCEFR = (userCEFR: string, wordCEFR: string): string[] => { - const userLevel = getCEFRToLevel(userCEFR) // A2 → 35 - const wordLevel = getCEFRToLevel(wordCEFR) // B1 → 50 - const difficulty = wordLevel - userLevel // 50 - 35 = 15 - - if (userCEFR === 'A1') { - return ['flip-memory', 'vocab-choice'] // 🛡️ A1保護:僅基礎2題型 - } else if (difficulty < -10) { - return ['sentence-reorder', 'sentence-fill'] // 🎯 簡單詞彙:應用題型 - } else if (difficulty >= -10 && difficulty <= 10) { - return ['sentence-fill', 'sentence-reorder'] // ⚖️ 適中詞彙:全方位題型 - } else { - return ['flip-memory', 'vocab-choice'] // 📚 困難詞彙:基礎題型 - } -} -``` - -### **測驗類型自動選擇流程** -``` -詞卡載入 → 檢查User.EnglishLevel vs Card.DifficultyLevel - ↓ -四情境判斷 → 生成適合的測驗類型列表 - ↓ -測驗隊列生成 → 為每張詞卡建立對應的TestItem - ↓ -自動執行 → 系統自動選擇並執行測驗,用戶零選擇負擔 -``` - ---- - -## 📈 效能和可維護性特點 - -### **效能優化** -1. **狀態分離** - UI狀態和業務狀態分開,減少不必要re-render -2. **組件懶載入** - 測驗組件按需渲染 -3. **API優化** - 批量載入、結果快取、自動重試 - -### **可維護性設計** -1. **單一職責** - 每個模組都有明確單一的職責 -2. **依賴倒置** - 高層模組不依賴底層實現細節 -3. **開放封閉** - 對擴展開放,對修改封閉 - -### **可測試性** -1. **純函數設計** - 工具函數都是純函數,易於測試 -2. **Mock友好** - 服務層可以輕易Mock -3. **狀態可預測** - Zustand狀態變化可預測和測試 - ---- - -## 🚀 新功能擴展指南 - -### **新增測驗類型** -1. **建立測驗組件** - `/components/learn/tests/NewTestType.tsx` -2. **更新TestRunner** - 添加新的case分支 -3. **更新CEFR適配** - 在cefrUtils.ts中添加新類型 -4. **更新類型定義** - 在useLearnStore.ts中添加新的ReviewMode - -### **新增功能模組** -1. **建立組件** - 放在適當的/components/目錄 -2. **建立狀態** - 在Zustand store中添加狀態 -3. **建立服務** - 在/lib/services/中添加API服務 -4. **整合到頁面** - 在page.tsx中組合使用 - ---- - -## 📚 與原始架構對比 - -### **改進前 (原始架構)** -- ❌ **單一巨型檔案** - 2428行難以維護 -- ❌ **狀態混亂** - 多個useState和useEffect -- ❌ **邏輯耦合** - UI和業務邏輯混合 -- ❌ **錯誤處理分散** - 每個地方都有不同的錯誤處理 - -### **改進後 (企業級架構)** -- ✅ **模組化設計** - 15個專門模組,每個<300行 -- ✅ **狀態集中化** - Zustand統一管理 -- ✅ **關注點分離** - UI、狀態、服務、錯誤各司其職 -- ✅ **系統化錯誤處理** - 統一的錯誤處理和恢復機制 - -### **量化改進成果** -| 指標 | 改進前 | 改進後 | 改善幅度 | -|------|--------|--------|----------| -| **主檔案行數** | 2428行 | 215行 | **-91.1%** | -| **模組數量** | 1個 | 15個 | **+1400%** | -| **組件可復用性** | 0% | 100% | **+100%** | -| **錯誤處理覆蓋** | 30% | 95% | **+65%** | -| **開發體驗** | 困難 | 優秀 | **質的提升** | - ---- - -## 🎪 最佳實踐建議 - -### **開發新功能時** -1. **先設計狀態** - 在Zustand store中定義狀態結構 -2. **再建立服務** - 在service層實現API和業務邏輯 -3. **最後實現UI** - 建立組件並連接狀態 - -### **維護現有功能時** -1. **定位問題層次** - UI問題→組件層,邏輯問題→服務層,狀態問題→store層 -2. **單層修改** - 避免跨層修改,保持架構清晰 -3. **測試驅動** - 修改前先寫測試,確保不破壞現有功能 - -### **效能調優時** -1. **狀態最小化** - 只在store中保存必要狀態 -2. **組件memo化** - 對重複渲染的組件使用React.memo -3. **API優化** - 使用快取和批量請求 - -這個架構確保了**高穩定性**、**高可維護性**和**高可擴展性**,能夠應對複雜的智能複習系統需求。 \ No newline at end of file