diff --git a/Review-Tests組件架構優化計劃.md b/Review-Tests組件架構優化計劃.md new file mode 100644 index 0000000..32a0f17 --- /dev/null +++ b/Review-Tests組件架構優化計劃.md @@ -0,0 +1,189 @@ +# Review-Tests 組件架構優化計劃 + +## 🔍 當前架構問題分析 + +### **檔案大小與複雜度** +- **FlipMemoryTest.tsx**: 9350 bytes (過大) +- **SentenceFillTest.tsx**: 9513 bytes (過大) +- **SentenceReorderTest.tsx**: 8084 bytes (較大) +- 單一組件承擔太多責任 + +### **Props 介面不一致** +```typescript +// FlipMemoryTest - 有 synonyms +interface FlipMemoryTestProps { + synonyms?: string[] + // ... +} + +// VocabChoiceTest - 沒有 synonyms +interface VocabChoiceTestProps { + // 缺少 synonyms + // ... +} +``` + +### **程式碼重複問題** +1. **AudioPlayer 重複引用** - 每個組件都獨立處理音頻 +2. **狀態管理重複** - 相似的 useState 邏輯 +3. **UI 模式重複** - 按鈕、卡片、回饋機制 +4. **錯誤處理重複** - onReportError 邏輯分散 + +## 🎯 優化目標 + +### **1. 統一資料介面** +```typescript +// types/review.ts +interface ReviewCardData { + id: string + word: string + definition: string + example: string + translation: string + pronunciation?: string + synonyms: string[] + difficultyLevel: string + exampleTranslation: string + filledQuestionText?: string + exampleImage?: string +} + +interface BaseReviewProps { + cardData: ReviewCardData + onAnswer: (answer: string) => void + onReportError: () => void + disabled?: boolean +} +``` + +### **2. 創建共用 Hook** +```typescript +// hooks/useReviewLogic.ts +export const useReviewLogic = () => { + // 統一的答案驗證 + // 共用的狀態管理 + // 統一的錯誤處理 + // 音頻播放邏輯 +} +``` + +### **3. 抽取共用 UI 組件** +``` +components/review/shared/ +├── AudioSection.tsx // 音頻播放區域 +├── CardHeader.tsx // 詞卡標題和基本資訊 +├── SynonymsDisplay.tsx // 同義詞顯示 +├── ConfidenceButtons.tsx // 信心度選擇按鈕 +├── ErrorReportButton.tsx // 錯誤回報按鈕 +├── DifficultyBadge.tsx // 難度等級標籤 +└── AnswerFeedback.tsx // 答案回饋機制 +``` + +### **4. 重構測試組件** +```typescript +// 每個測試組件專注於核心邏輯 +export const FlipMemoryTest: React.FC = ({ cardData, ...props }) => { + const { /* 共用邏輯 */ } = useReviewLogic() + + return ( +
+ + + {/* 翻卡特定邏輯 */} + + +
+ ) +} +``` + +## 📋 實施階段 + +### **階段 1: 基礎架構** ✅ **已完成** +- [x] 創建統一的 TypeScript 介面 (`types/review.ts`) +- [x] 建立共用 Hook (`hooks/useReviewLogic.ts`) +- [x] 抽取基礎 UI 組件 (6個共用組件) + - [x] `CardHeader.tsx` - 詞卡標題和基本資訊 + - [x] `SynonymsDisplay.tsx` - 同義詞顯示 + - [x] `DifficultyBadge.tsx` - 難度等級標籤 + - [x] `AudioSection.tsx` - 音頻播放區域 + - [x] `ConfidenceButtons.tsx` - 信心度選擇按鈕 + - [x] `ErrorReportButton.tsx` - 錯誤回報按鈕 + +### **階段 2: 重構現有組件** 🚧 **進行中** +- [x] FlipMemoryTest 重構 (`FlipMemoryTest.optimized.tsx` 完成,9350→6788 bytes) +- [ ] 切換到優化版本 (目前系統仍使用原始版本) +- [ ] VocabChoiceTest 重構 +- [ ] SentenceFillTest 重構 + +### **階段 3: 統一整合** ⏳ **待執行** +- [ ] 更新 review-design 頁面 +- [ ] 統一 props 傳遞 +- [ ] 測試所有功能 + +### **階段 4: 優化與測試** ⏳ **待執行** +- [ ] 效能優化 +- [ ] 錯誤處理改善 +- [ ] 使用者體驗統一 + +## 🎯 **當前狀況** (2025-09-28 16:30) + +### **已建立的檔案** +``` +frontend/ +├── types/review.ts (統一介面) +├── hooks/useReviewLogic.ts (共用邏輯) +├── components/review/shared/ (共用組件) +│ ├── CardHeader.tsx +│ ├── SynonymsDisplay.tsx +│ ├── DifficultyBadge.tsx +│ ├── AudioSection.tsx +│ ├── ConfidenceButtons.tsx +│ ├── ErrorReportButton.tsx +│ └── index.ts +└── components/review/review-tests/ + ├── FlipMemoryTest.tsx (原始版本 - 目前使用中) + └── FlipMemoryTest.optimized.tsx (重構版本 - 待啟用) +``` + +### **版本對比** +- **原始版本**: 9350 bytes,包含重複邏輯 +- **優化版本**: 6788 bytes,使用共用組件 (節省 27%) + +### **下一步行動** +1. 切換到優化版本 (重命名檔案) +2. 測試功能正常性 +3. 繼續重構其他組件 + +## 🎯 預期效果 + +### **程式碼品質** +- ✅ 減少 50% 程式碼重複 +- ✅ 組件大小縮減至 3-5KB +- ✅ 統一的介面和體驗 + +### **維護性** +- ✅ 新增測試類型更容易 +- ✅ Bug 修復影響範圍更小 +- ✅ 程式碼更容易理解 + +### **功能擴展** +- ✅ 同義詞功能統一整合 +- ✅ 新功能 (如圖片) 易於添加 +- ✅ 響應式設計更一致 + +## ⚠️ 風險評估 + +### **重構風險** +- **中等風險**: 需要修改多個檔案 +- **測試需求**: 需要全面測試所有測試類型 +- **向後相容**: 確保現有功能不受影響 + +### **建議策略** +1. **漸進式重構** - 一次重構一個組件 +2. **保留備份** - 重構前做 git commit +3. **充分測試** - 每個階段都要測試 + +--- + +*此計劃基於當前 review-tests 組件的架構分析,旨在提升程式碼品質和維護性。* \ No newline at end of file diff --git a/frontend/components/review/review-tests/FlipMemoryTest.optimized.tsx b/frontend/components/review/review-tests/FlipMemoryTest.optimized.tsx new file mode 100644 index 0000000..6cb80a9 --- /dev/null +++ b/frontend/components/review/review-tests/FlipMemoryTest.optimized.tsx @@ -0,0 +1,212 @@ +import { useState, useRef, useEffect } from 'react' +import { ConfidenceTestProps, ReviewCardData } from '@/types/review' +import { useReviewLogic } from '@/hooks/useReviewLogic' +import { + CardHeader, + AudioSection, + ConfidenceButtons, + ErrorReportButton +} from '@/components/review/shared' + +// 優化後的 FlipMemoryTest 組件 +export const FlipMemoryTestNew: React.FC = ({ + cardData, + onAnswer, + onConfidenceSubmit, + onReportError, + disabled = false +}) => { + // 使用共用邏輯 Hook + const { + confidence, + submitConfidence, + generateResult + } = useReviewLogic({ + cardData, + testType: 'FlipMemoryTest' + }) + + // 翻卡特定狀態 + const [isFlipped, setIsFlipped] = useState(false) + const [cardHeight, setCardHeight] = useState(400) + const frontRef = useRef(null) + const backRef = useRef(null) + + // 動態計算卡片高度 + useEffect(() => { + const updateCardHeight = () => { + if (backRef.current) { + const backHeight = backRef.current.scrollHeight + const minHeightByScreen = window.innerWidth <= 480 ? 300 : + window.innerWidth <= 768 ? 350 : 400 + const finalHeight = Math.max(minHeightByScreen, backHeight) + setCardHeight(finalHeight) + } + } + + setTimeout(updateCardHeight, 100) + window.addEventListener('resize', updateCardHeight) + return () => window.removeEventListener('resize', updateCardHeight) + }, [cardData]) + + // 處理信心度提交 + const handleConfidenceSubmit = (level: number) => { + submitConfidence(level as any) + onConfidenceSubmit(level) + + // 生成並傳遞答案結果 + const result = generateResult() + onAnswer(`confidence_${level}`) + } + + return ( +
+ {/* 翻卡區域 */} +
+
+
setIsFlipped(!isFlipped)} + > + {/* 正面 - 單字 */} +
+
+
+

+ {cardData.word} +

+ + + +

+ 點擊翻面查看答案 +

+
+
+
+ + {/* 背面 - 詳細資訊 */} +
+
+
+ + + {/* 例句區域 */} +
+

例句

+

+ {cardData.example} +

+

+ {cardData.exampleTranslation} +

+
+ + {/* 音頻播放 */} + + +

+ 請評估你對這個單字的熟悉程度 +

+
+
+
+
+
+
+ + {/* 信心度評估 */} + {isFlipped && ( +
+ + +
+ +
+
+ )} + + {/* 翻卡提示 */} + {!isFlipped && ( +
+

+ 💡 點擊卡片可以翻面查看詳細資訊 +

+
+ )} +
+ ) +} + +// 用於向後相容的包裝器 (暫時保留舊介面) +interface LegacyFlipMemoryTestProps { + word: string + definition: string + example: string + exampleTranslation: string + pronunciation?: string + synonyms?: string[] + difficultyLevel: string + onConfidenceSubmit: (level: number) => void + onReportError: () => void + disabled?: boolean +} + +// 預設匯出使用 Legacy 包裝器以保持向後相容 +export const FlipMemoryTest: React.FC = (props) => { + const cardData: ReviewCardData = { + id: `temp_${props.word}`, + word: props.word, + definition: props.definition, + example: props.example, + translation: props.exampleTranslation || '', // 使用 exampleTranslation 作為 translation + pronunciation: props.pronunciation, + synonyms: props.synonyms || [], + difficultyLevel: props.difficultyLevel, + exampleTranslation: props.exampleTranslation + } + + return ( + {}} + onConfidenceSubmit={props.onConfidenceSubmit} + onReportError={props.onReportError} + disabled={props.disabled} + /> + ) +} \ No newline at end of file diff --git a/frontend/components/review/review-tests/SentenceFillTest.optimized.tsx b/frontend/components/review/review-tests/SentenceFillTest.optimized.tsx new file mode 100644 index 0000000..8c44f6d --- /dev/null +++ b/frontend/components/review/review-tests/SentenceFillTest.optimized.tsx @@ -0,0 +1,220 @@ +import { useState, useMemo } from 'react' +import { FillTestProps, ReviewCardData } from '@/types/review' +import { useReviewLogic } from '@/hooks/useReviewLogic' +import { + CardHeader, + AudioSection, + ErrorReportButton +} from '@/components/review/shared' +import { getCorrectAnswer } from '@/utils/answerExtractor' + +// 優化後的 SentenceFillTest 組件 +export const SentenceFillTest: React.FC = ({ + cardData, + onAnswer, + onReportError, + disabled = false +}) => { + // 使用共用邏輯 Hook + const { + userAnswer, + feedback, + isSubmitted, + setUserAnswer, + submitAnswer + } = useReviewLogic({ + cardData, + testType: 'SentenceFillTest' + }) + + // 填空測試特定狀態 + const [inputValue, setInputValue] = useState('') + + // 獲取正確答案 + const correctAnswer = useMemo(() => { + return getCorrectAnswer(cardData.filledQuestionText || cardData.example, cardData.word) + }, [cardData.filledQuestionText, cardData.example, cardData.word]) + + // 處理答案提交 + const handleSubmit = () => { + if (isSubmitted || disabled || !inputValue.trim()) return + + const result = submitAnswer(inputValue.trim()) + onAnswer(inputValue.trim()) + } + + // 處理 Enter 鍵提交 + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleSubmit() + } + } + + return ( +
+ {/* 詞卡標題 */} + + +
+ {/* 標題區 */} +
+

句子填空

+
+ + {/* 填空句子顯示 */} +
+

完成下列句子

+
+ {(cardData.filledQuestionText || cardData.example).split('____').map((part, index, array) => ( + + {part} + {index < array.length - 1 && ( + + ____ + + )} + + ))} +
+
+ + {/* 答案輸入區 */} +
+ + setInputValue(e.target.value)} + onKeyPress={handleKeyPress} + disabled={disabled || isSubmitted} + placeholder="請輸入答案..." + className={` + w-full px-4 py-3 rounded-lg border-2 text-lg + ${isSubmitted + ? feedback?.isCorrect + ? 'border-green-500 bg-green-50' + : 'border-red-500 bg-red-50' + : 'border-gray-300 focus:border-blue-500 focus:outline-none' + } + ${disabled ? 'opacity-50 cursor-not-allowed' : ''} + `} + /> +
+ + {/* 提交按鈕 */} + {!isSubmitted && ( +
+ +
+ )} + + {/* 結果回饋 */} + {feedback && ( +
+

+ {feedback.explanation} +

+
+ )} + + {/* 翻譯和音頻 */} +
+
+

中文翻譯

+

{cardData.exampleTranslation}

+
+ + +
+ + {/* 例句圖片 */} + {cardData.exampleImage && ( +
+ {`Example +
+ )} + + {/* 底部按鈕 */} +
+ +
+
+
+ ) +} + +// 向後相容包裝器 +interface LegacySentenceFillTestProps { + word: string + definition: string + example: string + filledQuestionText?: string + exampleTranslation: string + pronunciation?: string + difficultyLevel: string + exampleImage?: string + onAnswer: (answer: string) => void + onReportError: () => void + onImageClick?: (image: string) => void + disabled?: boolean +} + +export const SentenceFillTestLegacy: React.FC = (props) => { + const cardData: ReviewCardData = { + id: `temp_${props.word}`, + word: props.word, + definition: props.definition, + example: props.example, + translation: props.exampleTranslation || '', + pronunciation: props.pronunciation, + synonyms: [], + difficultyLevel: props.difficultyLevel, + exampleTranslation: props.exampleTranslation, + filledQuestionText: props.filledQuestionText, + exampleImage: props.exampleImage + } + + return ( + + ) +} \ No newline at end of file diff --git a/frontend/components/review/review-tests/VocabChoiceTest.optimized.tsx b/frontend/components/review/review-tests/VocabChoiceTest.optimized.tsx new file mode 100644 index 0000000..a7a5433 --- /dev/null +++ b/frontend/components/review/review-tests/VocabChoiceTest.optimized.tsx @@ -0,0 +1,170 @@ +import { useState } from 'react' +import { ChoiceTestProps, ReviewCardData } from '@/types/review' +import { useReviewLogic } from '@/hooks/useReviewLogic' +import { + CardHeader, + AudioSection, + ErrorReportButton +} from '@/components/review/shared' + +// 優化後的 VocabChoiceTest 組件 +export const VocabChoiceTest: React.FC = ({ + cardData, + options, + onAnswer, + onReportError, + disabled = false +}) => { + // 使用共用邏輯 Hook + const { + userAnswer, + feedback, + isSubmitted, + submitAnswer + } = useReviewLogic({ + cardData, + testType: 'VocabChoiceTest' + }) + + const [selectedAnswer, setSelectedAnswer] = useState(null) + + // 處理選項點擊 + const handleOptionClick = (option: string) => { + if (isSubmitted || disabled) return + + setSelectedAnswer(option) + const result = submitAnswer(option) + onAnswer(option) + } + + return ( +
+ {/* 音頻播放區 */} + + +
+ {/* 標題區 */} +
+

詞彙選擇

+
+ + {/* 指示文字 */} +

+ 請選擇符合上述定義的英文詞彙: +

+ + {/* 定義顯示區 */} +
+
+

定義

+

{cardData.definition}

+
+
+ + {/* 選項區域 - 響應式網格布局 */} +
+ {options.map((option, idx) => { + const isSelected = selectedAnswer === option + const isCorrect = feedback && feedback.isCorrect && isSelected + const isWrong = feedback && !feedback.isCorrect && isSelected + + return ( + + ) + })} +
+ + {/* 結果回饋 */} + {feedback && ( +
+

+ {feedback.explanation} +

+
+ )} + + {/* 例句區域 */} +
+

例句

+

{cardData.example}

+

{cardData.exampleTranslation}

+
+ + {/* 底部按鈕 */} +
+ +
+
+
+ ) +} + +// 向後相容包裝器 +interface LegacyVocabChoiceTestProps { + word: string + definition: string + example: string + exampleTranslation: string + pronunciation?: string + difficultyLevel: string + options: string[] + onAnswer: (answer: string) => void + onReportError: () => void + disabled?: boolean +} + +export const VocabChoiceTestLegacy: React.FC = (props) => { + const cardData: ReviewCardData = { + id: `temp_${props.word}`, + word: props.word, + definition: props.definition, + example: props.example, + translation: props.exampleTranslation || '', + pronunciation: props.pronunciation, + synonyms: [], // VocabChoiceTest 原來沒有 synonyms + difficultyLevel: props.difficultyLevel, + exampleTranslation: props.exampleTranslation + } + + return ( + + ) +} \ No newline at end of file diff --git a/frontend/components/review/shared/AudioSection.tsx b/frontend/components/review/shared/AudioSection.tsx new file mode 100644 index 0000000..97a3ef1 --- /dev/null +++ b/frontend/components/review/shared/AudioSection.tsx @@ -0,0 +1,29 @@ +import AudioPlayer from '@/components/AudioPlayer' + +interface AudioSectionProps { + word: string + pronunciation?: string + className?: string + showPronunciation?: boolean +} + +export const AudioSection: React.FC = ({ + word, + pronunciation, + className = '', + showPronunciation = true +}) => { + return ( +
+ {/* 音頻播放器 */} + + + {/* 發音符號 */} + {showPronunciation && pronunciation && ( + + {pronunciation} + + )} +
+ ) +} \ No newline at end of file diff --git a/frontend/components/review/shared/CardHeader.tsx b/frontend/components/review/shared/CardHeader.tsx new file mode 100644 index 0000000..087051a --- /dev/null +++ b/frontend/components/review/shared/CardHeader.tsx @@ -0,0 +1,53 @@ +import { ReviewCardData } from '@/types/review' +import { SynonymsDisplay } from './SynonymsDisplay' +import { DifficultyBadge } from './DifficultyBadge' + +interface CardHeaderProps { + cardData: ReviewCardData + showTranslation?: boolean + className?: string +} + +export const CardHeader: React.FC = ({ + cardData, + showTranslation = true, + className = '' +}) => { + return ( +
+ {/* 單字標題 */} +
+

+ {cardData.word} +

+ + {/* 發音 */} + {cardData.pronunciation && ( +

+ {cardData.pronunciation} +

+ )} + + {/* 翻譯 */} + {showTranslation && ( +

+ {cardData.translation} +

+ )} +
+ + {/* 難度等級和同義詞 */} +
+ + +
+ + {/* 定義 */} +
+

+ {cardData.definition} +

+
+
+ ) +} \ No newline at end of file diff --git a/frontend/components/review/shared/ConfidenceButtons.tsx b/frontend/components/review/shared/ConfidenceButtons.tsx new file mode 100644 index 0000000..929e8be --- /dev/null +++ b/frontend/components/review/shared/ConfidenceButtons.tsx @@ -0,0 +1,61 @@ +import { ConfidenceLevel } from '@/types/review' + +interface ConfidenceButtonsProps { + selectedLevel?: ConfidenceLevel | undefined + onSelect: (level: ConfidenceLevel) => void + disabled?: boolean + className?: string +} + +export const ConfidenceButtons: React.FC = ({ + selectedLevel, + onSelect, + disabled = false, + className = '' +}) => { + const confidenceLevels: { level: ConfidenceLevel; label: string; color: string }[] = [ + { level: 1, label: '完全不會', color: 'bg-red-500 hover:bg-red-600' }, + { level: 2, label: '不太會', color: 'bg-orange-500 hover:bg-orange-600' }, + { level: 3, label: '一般', color: 'bg-yellow-500 hover:bg-yellow-600' }, + { level: 4, label: '還算會', color: 'bg-blue-500 hover:bg-blue-600' }, + { level: 5, label: '非常熟悉', color: 'bg-green-500 hover:bg-green-600' } + ] + + return ( +
+

+ 對這個單字的熟悉程度如何? +

+ +
+ {confidenceLevels.map(({ level, label, color }) => ( + + ))} +
+ + {selectedLevel && ( +

+ 已選擇: {confidenceLevels.find(c => c.level === selectedLevel)?.label} +

+ )} +
+ ) +} \ No newline at end of file diff --git a/frontend/components/review/shared/DifficultyBadge.tsx b/frontend/components/review/shared/DifficultyBadge.tsx new file mode 100644 index 0000000..08fd056 --- /dev/null +++ b/frontend/components/review/shared/DifficultyBadge.tsx @@ -0,0 +1,36 @@ +interface DifficultyBadgeProps { + level: string + className?: string +} + +export const DifficultyBadge: React.FC = ({ + level, + className = '' +}) => { + const getBadgeStyle = (level: string) => { + switch (level?.toUpperCase()) { + case 'A1': + return 'bg-green-100 text-green-800 border-green-200' + case 'A2': + return 'bg-blue-100 text-blue-800 border-blue-200' + case 'B1': + return 'bg-yellow-100 text-yellow-800 border-yellow-200' + case 'B2': + return 'bg-orange-100 text-orange-800 border-orange-200' + case 'C1': + return 'bg-red-100 text-red-800 border-red-200' + case 'C2': + return 'bg-purple-100 text-purple-800 border-purple-200' + default: + return 'bg-gray-100 text-gray-800 border-gray-200' + } + } + + return ( + + {level?.toUpperCase() || 'N/A'} + + ) +} \ No newline at end of file diff --git a/frontend/components/review/shared/ErrorReportButton.tsx b/frontend/components/review/shared/ErrorReportButton.tsx new file mode 100644 index 0000000..e8c968e --- /dev/null +++ b/frontend/components/review/shared/ErrorReportButton.tsx @@ -0,0 +1,42 @@ +interface ErrorReportButtonProps { + onClick: () => void + className?: string + disabled?: boolean +} + +export const ErrorReportButton: React.FC = ({ + onClick, + className = '', + disabled = false +}) => { + return ( + + ) +} \ No newline at end of file diff --git a/frontend/components/review/shared/SynonymsDisplay.tsx b/frontend/components/review/shared/SynonymsDisplay.tsx new file mode 100644 index 0000000..cbd340f --- /dev/null +++ b/frontend/components/review/shared/SynonymsDisplay.tsx @@ -0,0 +1,33 @@ +interface SynonymsDisplayProps { + synonyms: string[] + className?: string + showLabel?: boolean +} + +export const SynonymsDisplay: React.FC = ({ + synonyms, + className = '', + showLabel = true +}) => { + if (!synonyms || synonyms.length === 0) { + return null + } + + return ( +
+ {showLabel && ( + 同義詞: + )} +
+ {synonyms.map((synonym, index) => ( + + {synonym} + + ))} +
+
+ ) +} \ No newline at end of file diff --git a/frontend/components/review/shared/index.ts b/frontend/components/review/shared/index.ts new file mode 100644 index 0000000..3830f3e --- /dev/null +++ b/frontend/components/review/shared/index.ts @@ -0,0 +1,7 @@ +// Review 測試共用組件匯出 +export { CardHeader } from './CardHeader' +export { SynonymsDisplay } from './SynonymsDisplay' +export { DifficultyBadge } from './DifficultyBadge' +export { AudioSection } from './AudioSection' +export { ConfidenceButtons } from './ConfidenceButtons' +export { ErrorReportButton } from './ErrorReportButton' \ No newline at end of file diff --git a/frontend/hooks/useReviewLogic.ts b/frontend/hooks/useReviewLogic.ts new file mode 100644 index 0000000..558e953 --- /dev/null +++ b/frontend/hooks/useReviewLogic.ts @@ -0,0 +1,92 @@ +import { useState, useCallback, useRef } from 'react' +import { ReviewCardData, AnswerFeedback, ConfidenceLevel, ReviewResult } from '@/types/review' + +interface UseReviewLogicProps { + cardData: ReviewCardData + testType: string +} + +export const useReviewLogic = ({ cardData, testType }: UseReviewLogicProps) => { + // 共用狀態 + const [userAnswer, setUserAnswer] = useState('') + const [feedback, setFeedback] = useState(null) + const [isSubmitted, setIsSubmitted] = useState(false) + const [confidence, setConfidence] = useState(undefined) + const [startTime] = useState(Date.now()) + + // 答案驗證邏輯 + const validateAnswer = useCallback((answer: string): AnswerFeedback => { + const correctAnswer = cardData.word.toLowerCase() + const normalizedAnswer = answer.toLowerCase().trim() + + // 檢查是否為正確答案或同義詞 + const isCorrect = normalizedAnswer === correctAnswer || + cardData.synonyms.some(synonym => + synonym.toLowerCase() === normalizedAnswer) + + return { + isCorrect, + userAnswer: answer, + correctAnswer: cardData.word, + explanation: isCorrect ? + '答案正確!' : + `正確答案是 "${cardData.word}"${cardData.synonyms.length > 0 ? + `,同義詞包括:${cardData.synonyms.join(', ')}` : ''}` + } + }, [cardData]) + + // 提交答案 + const submitAnswer = useCallback((answer: string) => { + if (isSubmitted) return + + const result = validateAnswer(answer) + setUserAnswer(answer) + setFeedback(result) + setIsSubmitted(true) + + return result + }, [validateAnswer, isSubmitted]) + + // 提交信心度 + const submitConfidence = useCallback((level: ConfidenceLevel) => { + setConfidence(level) + }, []) + + // 生成測試結果 + const generateResult = useCallback((): ReviewResult => { + return { + cardId: cardData.id, + testType, + isCorrect: feedback?.isCorrect ?? false, + confidence, + timeSpent: Math.round((Date.now() - startTime) / 1000), + userAnswer + } + }, [cardData.id, testType, feedback, confidence, startTime, userAnswer]) + + // 重置狀態 + const reset = useCallback(() => { + setUserAnswer('') + setFeedback(null) + setIsSubmitted(false) + setConfidence(null) + }, []) + + return { + // 狀態 + userAnswer, + feedback, + isSubmitted, + confidence, + + // 方法 + setUserAnswer, + submitAnswer, + submitConfidence, + generateResult, + reset, + + // 輔助方法 + validateAnswer + } +} \ No newline at end of file diff --git a/frontend/types/review.ts b/frontend/types/review.ts new file mode 100644 index 0000000..29b3e18 --- /dev/null +++ b/frontend/types/review.ts @@ -0,0 +1,72 @@ +// Review 系統統一資料介面定義 + +export interface ReviewCardData { + id: string + word: string + definition: string + example: string + translation: string + pronunciation?: string + synonyms: string[] + difficultyLevel: string + exampleTranslation: string + filledQuestionText?: string + exampleImage?: string + // 學習相關欄位 + masteryLevel?: number + timesReviewed?: number + isFavorite?: boolean +} + +export interface BaseReviewProps { + cardData: ReviewCardData + onAnswer: (answer: string) => void + onReportError: () => void + disabled?: boolean +} + +// 特定測試類型的額外 Props +export interface ChoiceTestProps extends BaseReviewProps { + options: string[] +} + +export interface ConfidenceTestProps extends BaseReviewProps { + onConfidenceSubmit: (level: number) => void +} + +export interface FillTestProps extends BaseReviewProps { + // 填空測試特定屬性 +} + +export interface ReorderTestProps extends BaseReviewProps { + // 重排測試特定屬性 +} + +export interface ListeningTestProps extends BaseReviewProps { + // 聽力測試特定屬性 +} + +export interface SpeakingTestProps extends BaseReviewProps { + // 口說測試特定屬性 +} + +// 答案回饋類型 +export interface AnswerFeedback { + isCorrect: boolean + userAnswer: string + correctAnswer: string + explanation?: string +} + +// 信心度等級 +export type ConfidenceLevel = 1 | 2 | 3 | 4 | 5 + +// 測試結果 +export interface ReviewResult { + cardId: string + testType: string + isCorrect: boolean + confidence?: ConfidenceLevel + timeSpent: number + userAnswer: string +} \ No newline at end of file