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:
parent
8bef1e0d59
commit
50a0a79d72
|
|
@ -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`(學習) 更精確地描述這個功能的本質。
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
{/* 同義詞區塊 */}
|
{/* 同義詞區塊 */}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
disabled={!fillAnswer.trim() || showResult}
|
||||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
className={`px-6 py-2 rounded-lg transition-colors ${
|
||||||
>
|
!fillAnswer.trim() || showResult
|
||||||
確認答案
|
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||||
</button>
|
: 'bg-blue-600 text-white hover:bg-blue-700'
|
||||||
)}
|
}`}
|
||||||
|
>
|
||||||
|
{!fillAnswer.trim() ? '請先輸入答案' : showResult ? '已確認' : '確認答案'}
|
||||||
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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優化** - 使用快取和批量請求
|
|
||||||
|
|
||||||
這個架構確保了**高穩定性**、**高可維護性**和**高可擴展性**,能夠應對複雜的智能複習系統需求。
|
|
||||||
Loading…
Reference in New Issue