feat: 完成前端動態答案推導系統和UI組件優化

## 🎯 動態答案推導系統

### 新增核心工具
- answerExtractor.ts: 從例句和挖空題目動態推導正確答案
- 支援單空格和多空格情況
- 完整的錯誤處理和降級機制

### SentenceFillTest 組件升級
- 新增 filledQuestionText 屬性支援
- 實作 renderFilledSentence() 智能渲染
- 動態計算正確答案,無需資料庫存儲
- 改善確認答案按鈕:始終可見,智能狀態提示

## 🎨 UI/UX 組件優化

### 填空題交互改善
- 確認答案按鈕始終顯示
- 智能狀態文字:「請先輸入答案」→「確認答案」→「已確認」
- 動態答案驗證和音頻播放

### 其他組件調整
- VocabChoiceTest: 優化音頻和發音顯示
- FlipMemoryTest: 改善例句區塊布局
- SentenceListeningTest: 優化結果顯示格式
- SentenceReorderTest: 調整音頻控制位置

## 📊 系統優勢

 **無需額外存儲**: 答案從現有資料動態推導
 **資料一致性**: 答案永遠與例句匹配
 **智能降級**: 後端無資料時自動使用前端邏輯
 **用戶體驗**: 更清晰的操作指引和狀態回饋

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
鄭沛軒 2025-09-28 02:24:59 +08:00
parent 8bef1e0d59
commit 50a0a79d72
12 changed files with 296 additions and 903 deletions

View File

@ -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`(學習) 更精確地描述這個功能的本質。

View File

@ -44,6 +44,7 @@ export default function ReviewTestsPage() {
word: currentCard.word, word: currentCard.word,
definition: currentCard.definition, definition: currentCard.definition,
example: currentCard.example, example: currentCard.example,
filledQuestionText: currentCard.filledQuestionText,
exampleTranslation: currentCard.exampleTranslation, exampleTranslation: currentCard.exampleTranslation,
pronunciation: currentCard.pronunciation, pronunciation: currentCard.pronunciation,
difficultyLevel: currentCard.difficultyLevel, difficultyLevel: currentCard.difficultyLevel,
@ -56,6 +57,7 @@ export default function ReviewTestsPage() {
word: "loading...", word: "loading...",
definition: "Loading...", definition: "Loading...",
example: "Loading...", example: "Loading...",
filledQuestionText: undefined,
exampleTranslation: "載入中...", exampleTranslation: "載入中...",
pronunciation: "", pronunciation: "",
difficultyLevel: "A1", difficultyLevel: "A1",
@ -188,6 +190,7 @@ export default function ReviewTestsPage() {
word={mockCardData.word} word={mockCardData.word}
definition={mockCardData.definition} definition={mockCardData.definition}
example={mockCardData.example} example={mockCardData.example}
filledQuestionText={mockCardData.filledQuestionText}
exampleTranslation={mockCardData.exampleTranslation} exampleTranslation={mockCardData.exampleTranslation}
pronunciation={mockCardData.pronunciation} pronunciation={mockCardData.pronunciation}
difficultyLevel={mockCardData.difficultyLevel} difficultyLevel={mockCardData.difficultyLevel}

View File

@ -241,7 +241,7 @@ const CardPreviewItem: React.FC<CardPreviewItemProps> = ({
{card.example && ( {card.example && (
<div> <div>
<span className="font-medium text-gray-700"></span> <span className="font-medium text-gray-700"></span>
<p className="text-gray-900 italic">"{card.example}"</p> <p className="text-gray-900 italic">{card.example}</p>
{card.exampleTranslation && ( {card.exampleTranslation && (
<p className="text-gray-600 text-sm mt-1">{card.exampleTranslation}</p> <p className="text-gray-600 text-sm mt-1">{card.exampleTranslation}</p>
)} )}

View File

@ -158,12 +158,12 @@ export const FlipMemoryTest: React.FC<FlipMemoryTestProps> = ({
<div className="bg-gray-50 rounded-lg p-4"> <div className="bg-gray-50 rounded-lg p-4">
<h3 className="font-semibold text-gray-900 mb-2 text-left"></h3> <h3 className="font-semibold text-gray-900 mb-2 text-left"></h3>
<div className="relative"> <div className="relative">
<p className="text-gray-700 italic mb-2 text-left pr-12">"{example}"</p> <p className="text-gray-700 italic mb-2 text-left pr-12">{example}</p>
<div className="absolute bottom-0 right-0" onClick={(e) => e.stopPropagation()}> <div className="absolute bottom-0 right-0" onClick={(e) => e.stopPropagation()}>
<AudioPlayer text={example} /> <AudioPlayer text={example} />
</div> </div>
</div> </div>
<p className="text-gray-600 text-sm text-left">"{exampleTranslation}"</p> <p className="text-gray-600 text-sm text-left">{exampleTranslation}</p>
</div> </div>
{/* 同義詞區塊 */} {/* 同義詞區塊 */}

View File

@ -1,10 +1,12 @@
import { useState } from 'react' import { useState, useMemo } from 'react'
import AudioPlayer from '@/components/AudioPlayer' import AudioPlayer from '@/components/AudioPlayer'
import { getCorrectAnswer } from '@/utils/answerExtractor'
interface SentenceFillTestProps { interface SentenceFillTestProps {
word: string word: string
definition: string definition: string
example: string example: string
filledQuestionText?: string
exampleTranslation: string exampleTranslation: string
pronunciation?: string pronunciation?: string
difficultyLevel: string difficultyLevel: string
@ -19,6 +21,7 @@ export const SentenceFillTest: React.FC<SentenceFillTestProps> = ({
word, word,
definition, definition,
example, example,
filledQuestionText,
exampleTranslation, exampleTranslation,
pronunciation, pronunciation,
difficultyLevel, difficultyLevel,
@ -44,10 +47,62 @@ export const SentenceFillTest: React.FC<SentenceFillTestProps> = ({
} }
} }
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 inputWidth = Math.max(100, Math.max(targetWordLength * 12, fillAnswer.length * 12 + 20))
// 🆕 智能填空渲染:優先使用後端提供的挖空題目
const renderFilledSentence = () => {
if (!filledQuestionText) {
// 降級處理:使用原有的前端挖空邏輯
return renderSentenceWithInput();
}
// 使用後端提供的挖空題目
const parts = filledQuestionText.split('____');
return (
<div className="text-lg text-gray-700 leading-relaxed">
{parts.map((part, index) => (
<span key={index}>
{part}
{index < parts.length - 1 && (
<span className="relative inline-block mx-1">
<input
type="text"
value={fillAnswer}
onChange={(e) => 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 && (
<span
className="absolute inset-0 flex items-center justify-center text-gray-400 pointer-events-none"
style={{ paddingBottom: '8px' }}
>
____
</span>
)}
</span>
)}
</span>
))}
</div>
);
}
// 將例句中的目標詞替換為輸入框 // 將例句中的目標詞替換為輸入框
const renderSentenceWithInput = () => { const renderSentenceWithInput = () => {
const parts = example.split(new RegExp(`\\b${word}\\b`, 'gi')) const parts = example.split(new RegExp(`\\b${word}\\b`, 'gi'))
@ -133,20 +188,23 @@ export const SentenceFillTest: React.FC<SentenceFillTestProps> = ({
{/* 填空句子區域 */} {/* 填空句子區域 */}
<div className="mb-6"> <div className="mb-6">
<div className="bg-gray-50 rounded-lg p-6"> <div className="bg-gray-50 rounded-lg p-6">
{renderSentenceWithInput()} {renderFilledSentence()}
</div> </div>
</div> </div>
{/* 操作按鈕 */} {/* 操作按鈕 */}
<div className="flex gap-3 mb-4"> <div className="flex gap-3 mb-4">
{!showResult && fillAnswer.trim() && (
<button <button
onClick={handleSubmit} onClick={handleSubmit}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors" disabled={!fillAnswer.trim() || showResult}
className={`px-6 py-2 rounded-lg transition-colors ${
!fillAnswer.trim() || showResult
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'
}`}
> >
{!fillAnswer.trim() ? '請先輸入答案' : showResult ? '已確認' : '確認答案'}
</button> </button>
)}
<button <button
onClick={() => setShowHint(!showHint)} onClick={() => setShowHint(!showHint)}
className="px-6 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors" className="px-6 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors"
@ -179,7 +237,7 @@ export const SentenceFillTest: React.FC<SentenceFillTestProps> = ({
{!isCorrect && ( {!isCorrect && (
<div className="mb-4"> <div className="mb-4">
<p className="text-gray-700 text-left"> <p className="text-gray-700 text-left">
<strong className="text-lg">{word}</strong> <strong className="text-lg">{correctAnswer}</strong>
</p> </p>
</div> </div>
)} )}
@ -187,18 +245,19 @@ export const SentenceFillTest: React.FC<SentenceFillTestProps> = ({
<div className="space-y-3"> <div className="space-y-3">
<div className="text-left"> <div className="text-left">
<p className="text-gray-600"> <p className="text-gray-600">
<strong></strong> {word && <span className="font-semibold text-left text-xl">{word}</span>}
{pronunciation && <span className="mx-2">{pronunciation}</span>} {pronunciation && <span className="mx-2">{pronunciation}</span>}
<AudioPlayer text={word} /> <AudioPlayer text={correctAnswer} />
</p> </p>
</div> </div>
<div className="text-left"> <div className="text-left">
<p className="text-gray-600"> <p className="text-gray-600">
<strong></strong>"{example}" {example}
<AudioPlayer text={example}/>
</p> </p>
<p className="text-gray-500 text-sm"> <p className="text-gray-500 text-sm">
<strong></strong>"{exampleTranslation}" {exampleTranslation}
</p> </p>
</div> </div>
</div> </div>

View File

@ -123,10 +123,13 @@ export const SentenceListeningTest: React.FC<SentenceListeningTestProps> = ({
<div className="space-y-3"> <div className="space-y-3">
<div className="text-left"> <div className="text-left">
<p className="text-gray-700"> <p className="text-gray-700">
<strong></strong>"{example}" <strong></strong>
</p>
<p className="text-gray-700">
{example}
</p> </p>
<p className="text-gray-600"> <p className="text-gray-600">
<strong></strong>"{exampleTranslation}" {exampleTranslation}
</p> </p>
</div> </div>
</div> </div>

View File

@ -195,7 +195,7 @@ export const SentenceReorderTest: React.FC<SentenceReorderTestProps> = ({
{!reorderResult && ( {!reorderResult && (
<div className="mb-4"> <div className="mb-4">
<p className="text-gray-700 text-left"> <p className="text-gray-700 text-left">
<strong className="text-lg">"{example}"</strong> <strong className="text-lg">{example}</strong>
</p> </p>
</div> </div>
)} )}
@ -203,14 +203,13 @@ export const SentenceReorderTest: React.FC<SentenceReorderTestProps> = ({
<div className="space-y-3"> <div className="space-y-3">
<div className="text-left"> <div className="text-left">
<div className="flex items-center text-gray-600"> <div className="flex items-center text-gray-600">
<strong></strong>
<AudioPlayer text={example} /> <AudioPlayer text={example} />
</div> </div>
</div> </div>
<div className="text-left"> <div className="text-left">
<p className="text-gray-600"> <p className="text-gray-600">
<strong></strong>{exampleTranslation} {exampleTranslation}
</p> </p>
</div> </div>
</div> </div>

View File

@ -118,20 +118,10 @@ export const VocabChoiceTest: React.FC<VocabChoiceTestProps> = ({
<div className="space-y-3"> <div className="space-y-3">
<div className="text-left"> <div className="text-left">
<div className="flex items-center text-gray-600"> <div className="flex items-center text-gray-600">
<strong></strong>
{pronunciation && <span className="mx-2">{pronunciation}</span>} {pronunciation && <span className="mx-2">{pronunciation}</span>}
<AudioPlayer text={word} /> <AudioPlayer text={word} />
</div> </div>
</div> </div>
<div className="text-left">
<p className="text-gray-600">
<strong></strong>"{example}"
</p>
<p className="text-gray-500 text-sm">
<strong></strong>"{exampleTranslation}"
</p>
</div>
</div> </div>
</div> </div>
)} )}

View File

@ -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();
}

View File

@ -2106,7 +2106,7 @@ export default function LearnPage() {
<div className="space-y-3"> <div className="space-y-3">
<div className="text-left"> <div className="text-left">
<p className="text-gray-600"> <p className="text-gray-600">
<strong></strong>{currentCard.exampleTranslation} {currentCard.exampleTranslation}
</p> </p>
</div> </div>
</div> </div>

View File

@ -129,12 +129,12 @@
<div className="bg-gray-50 rounded-lg p-4"> <div className="bg-gray-50 rounded-lg p-4">
<h3 className="font-semibold text-gray-900 mb-2 text-left">例句</h3> <h3 className="font-semibold text-gray-900 mb-2 text-left">例句</h3>
<div className="relative"> <div className="relative">
<p className="text-gray-700 italic mb-2 text-left pr-12">"{example}"</p> <p className="text-gray-700 italic mb-2 text-left pr-12">{example}</p>
<div className="absolute bottom-0 right-0"> <div className="absolute bottom-0 right-0">
<AudioPlayer text={example} /> <AudioPlayer text={example} />
</div> </div>
</div> </div>
<p className="text-gray-600 text-sm text-left">"{exampleTranslation}"</p> <p className="text-gray-600 text-sm text-left">{exampleTranslation}</p>
</div> </div>
{/* 同義詞區塊 */} {/* 同義詞區塊 */}
@ -295,7 +295,6 @@
<div className="space-y-3"> <div className="space-y-3">
<div className="text-left"> <div className="text-left">
<div className="flex items-center text-gray-600"> <div className="flex items-center text-gray-600">
<strong>發音:</strong>
<span className="mx-2">{pronunciation}</span> <span className="mx-2">{pronunciation}</span>
<AudioPlayer text={correctAnswer} /> <AudioPlayer text={correctAnswer} />
</div> </div>
@ -303,10 +302,10 @@
<div className="text-left"> <div className="text-left">
<p className="text-gray-600"> <p className="text-gray-600">
<strong>例句:</strong>"{example}" <strong>例句:</strong>{example}
</p> </p>
<p className="text-gray-500 text-sm"> <p className="text-gray-500 text-sm">
<strong>翻譯:</strong>"{exampleTranslation}" <strong>翻譯:</strong>{exampleTranslation}
</p> </p>
</div> </div>
</div> </div>
@ -600,7 +599,7 @@
{!reorderResult && ( {!reorderResult && (
<div className="mb-4"> <div className="mb-4">
<p className="text-gray-700 text-left"> <p className="text-gray-700 text-left">
正確答案是:<strong className="text-lg">"{correctSentence}"</strong> 正確答案是:<strong className="text-lg">{correctSentence}</strong>
</p> </p>
</div> </div>
)} )}
@ -608,14 +607,13 @@
<div className="space-y-3"> <div className="space-y-3">
<div className="text-left"> <div className="text-left">
<div className="flex items-center text-gray-600"> <div className="flex items-center text-gray-600">
<strong>發音:</strong>
<AudioPlayer text={correctSentence} /> <AudioPlayer text={correctSentence} />
</div> </div>
</div> </div>
<div className="text-left"> <div className="text-left">
<p className="text-gray-600"> <p className="text-gray-600">
<strong>中文翻譯:</strong>{translation} {translation}
</p> </p>
</div> </div>
</div> </div>
@ -771,10 +769,10 @@
<div className="space-y-3"> <div className="space-y-3">
<div className="text-left"> <div className="text-left">
<p className="text-gray-700"> <p className="text-gray-700">
<strong>正確例句:</strong>"{correctSentence}" {correctSentence}
</p> </p>
<p className="text-gray-600"> <p className="text-gray-600">
<strong>中文翻譯:</strong>"{translation}" {translation}
</p> </p>
</div> </div>
</div> </div>

View File

@ -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 (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
<Navigation />
<div className="max-w-4xl mx-auto px-4 py-8">
<ProgressTracker {...} />
<TestRunner />
<TaskListModal {...} />
{showComplete && <LearningComplete {...} />}
{modalImage && <ImageModal {...} />}
<Modal isOpen={showReportModal}>...</Modal>
</div>
</div>
)
}
```
#### **設計特點**
- ✅ **無業務邏輯** - 只負責渲染和事件分派
- ✅ **狀態訂閱** - 通過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 <FlipMemoryTest {...commonProps} onConfidenceSubmit={handleAnswer} />
case 'vocab-choice':
return <VocabChoiceTest {...commonProps} onAnswer={handleAnswer} />
// ... 其他測驗類型
}
```
### **測驗組件設計模式**
#### **統一接口設計**
所有測驗組件都遵循相同的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<string | null>(null)
const [showResult, setShowResult] = useState(false)
// 例SentenceReorderTest.tsx
const [shuffledWords, setShuffledWords] = useState<string[]>([])
const [arrangedWords, setArrangedWords] = useState<string[]>([])
```
---
## 🗄️ 狀態層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<void>
initializeTestQueue: (completedTests: any[]) => void
recordTestResult: (isCorrect: boolean, ...) => Promise<void>
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<ExtendedFlashcard[]>
// 載入已完成測驗 (智能狀態恢復)
static async loadCompletedTests(cardIds: string[]): Promise<any[]>
// 記錄測驗結果
static async recordTestResult(params: {...}): Promise<boolean>
// 生成測驗選項
static async generateTestOptions(cardId: string, testType: string): Promise<string[]>
// 驗證學習會話完整性
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 <FlipMemoryTest {...commonProps} onConfidenceSubmit={handleAnswer} />
case 'vocab-choice':
return <VocabChoiceTest {...commonProps} onAnswer={handleAnswer} />
// ...
}
```
#### **步驟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優化** - 使用快取和批量請求
這個架構確保了**高穩定性**、**高可維護性**和**高可擴展性**,能夠應對複雜的智能複習系統需求。