40 KiB
40 KiB
智能複習系統 - 前端功能規格書 (FFS)
目標讀者: 前端開發工程師、UI/UX設計師 版本: 1.0 日期: 2025-09-25
🎯 功能概述
智能複習系統前端負責呈現動態熟悉度、復習進度追蹤、學習統計等功能,提供直觀的學習體驗。
核心特色
- 實時熟悉度顯示: 詞彙熟悉度隨時間動態變化
- 智能復習提醒: 基於算法的個人化復習建議
- 進度可視化: 清晰的學習進度和統計圖表
- 響應式設計: 支援各種設備和屏幕尺寸
🏗️ 組件架構
頁面結構
src/
├── pages/
│ ├── ReviewPage/ # 復習頁面
│ ├── FlashcardListPage/ # 詞卡列表頁面
│ ├── StatisticsPage/ # 學習統計頁面
│ └── SettingsPage/ # 設定頁面
├── components/
│ ├── FlashcardItem/ # 詞卡組件
│ ├── MasteryIndicator/ # 熟悉度指示器
│ ├── ReviewSchedule/ # 復習排程組件
│ └── ProgressChart/ # 進度圖表組件
└── services/
├── reviewApi.js # 復習相關 API
├── masteryCalculator.js # 前端熟悉度計算
└── dateUtils.js # 日期工具函數
📱 核心組件設計
1. FlashcardItem 組件
功能需求
- 顯示詞彙、定義、例句
- 實時熟悉度指示器
- 復習狀態標示(到期、逾期、未到期)
- 點擊進入復習模式
Props 介面
interface FlashcardItemProps {
flashcard: {
id: number;
word: string;
definition: string;
baseMasteryLevel: number; // 後端提供的基礎熟悉度
lastReviewDate: string; // ISO 日期格式
nextReviewDate: string;
currentInterval: number;
timesCorrect: number;
totalReviews: number;
isOverdue: boolean;
overdueDays: number;
};
showMastery?: boolean; // 是否顯示熟悉度
onReviewClick?: (id: number) => void;
}
組件實現
export const FlashcardItem: React.FC<FlashcardItemProps> = ({
flashcard,
showMastery = true,
onReviewClick
}) => {
// 實時計算當前熟悉度
const currentMastery = useMemo(() => {
return calculateCurrentMastery(
flashcard.baseMasteryLevel,
flashcard.lastReviewDate
);
}, [flashcard.baseMasteryLevel, flashcard.lastReviewDate]);
// 判斷復習狀態
const reviewStatus = useMemo(() => {
const today = new Date().toISOString().split('T')[0];
const nextDate = flashcard.nextReviewDate;
if (nextDate < today) return 'overdue';
if (nextDate === today) return 'due';
return 'future';
}, [flashcard.nextReviewDate]);
return (
<div className={`flashcard-item ${reviewStatus}`}>
<div className="content">
<h3 className="word">{flashcard.word}</h3>
<p className="definition">{flashcard.definition}</p>
{showMastery && (
<MasteryIndicator
level={currentMastery}
isDecaying={currentMastery < flashcard.baseMasteryLevel}
/>
)}
<ReviewStatusBadge
status={reviewStatus}
overdueDays={flashcard.overdueDays}
nextDate={flashcard.nextReviewDate}
/>
</div>
<button
className="review-btn"
onClick={() => onReviewClick?.(flashcard.id)}
>
{reviewStatus === 'overdue' ? '逾期復習' :
reviewStatus === 'due' ? '立即復習' : '提前復習'}
</button>
</div>
);
};
2. MasteryIndicator 組件
功能需求
- 視覺化顯示熟悉度百分比
- 區分基礎熟悉度和當前熟悉度
- 衰減狀態提示
設計規格
interface MasteryIndicatorProps {
level: number; // 0-100
isDecaying?: boolean; // 是否正在衰減
showPercentage?: boolean; // 是否顯示百分比數字
size?: 'small' | 'medium' | 'large';
}
export const MasteryIndicator: React.FC<MasteryIndicatorProps> = ({
level,
isDecaying = false,
showPercentage = true,
size = 'medium'
}) => {
const getColor = (level: number, isDecaying: boolean) => {
if (isDecaying) return '#ff9500'; // 橙色表示衰減中
if (level >= 80) return '#34c759'; // 綠色表示熟悉
if (level >= 50) return '#007aff'; // 藍色表示中等
return '#ff3b30'; // 紅色表示需要加強
};
return (
<div className={`mastery-indicator ${size}`}>
<div className="progress-circle">
<svg viewBox="0 0 36 36">
<circle
cx="18" cy="18" r="15.915"
fill="transparent"
stroke="#e5e5e7"
strokeWidth="2"
/>
<circle
cx="18" cy="18" r="15.915"
fill="transparent"
stroke={getColor(level, isDecaying)}
strokeWidth="2"
strokeDasharray={`${level} 100`}
transform="rotate(-90 18 18)"
/>
</svg>
{showPercentage && (
<div className="percentage">
{level}%
{isDecaying && <span className="decay-icon">↓</span>}
</div>
)}
</div>
<div className="mastery-label">
{level >= 80 ? '熟悉' :
level >= 50 ? '中等' : '需加強'}
</div>
</div>
);
};
3. ReviewSchedule 組件
功能需求
- 顯示今日復習列表
- 按優先級排序(逾期 > 到期 > 提前復習)
- 復習進度追蹤
實現概要
export const ReviewSchedule: React.FC = () => {
const [dueCards, setDueCards] = useState<Flashcard[]>([]);
const [completedCount, setCompletedCount] = useState(0);
useEffect(() => {
loadDueCards();
}, []);
const loadDueCards = async () => {
const response = await reviewApi.getDueFlashcards();
setDueCards(response.data);
};
const handleReviewComplete = (cardId: number) => {
setDueCards(prev => prev.filter(card => card.id !== cardId));
setCompletedCount(prev => prev + 1);
};
// 按優先級排序
const sortedCards = useMemo(() => {
return [...dueCards].sort((a, b) => {
if (a.isOverdue !== b.isOverdue) {
return a.isOverdue ? -1 : 1; // 逾期優先
}
if (a.isOverdue && b.isOverdue) {
return b.overdueDays - a.overdueDays; // 逾期天數多的優先
}
return 0;
});
}, [dueCards]);
return (
<div className="review-schedule">
<div className="progress-header">
<h2>今日復習</h2>
<div className="progress">
{completedCount} / {dueCards.length + completedCount}
</div>
</div>
<div className="card-list">
{sortedCards.map(card => (
<FlashcardItem
key={card.id}
flashcard={card}
onReviewClick={() => startReview(card.id)}
/>
))}
</div>
</div>
);
};
4. ReviewPage 組件
功能需求
- 復習界面(翻卡、選擇題等)
- 信心程度評分 (1-5)
- 復習結果反饋
- 下一張卡片自動載入
🎓 複習方式設計
複習題型規劃
1. 翻卡題 (Flipcard)
- 操作方式: 顯示詞彙,學習者自己憑感覺評估記憶情況
- 學習效益: 對詞彙形成全面的初步印象
- 適用情境:
- A1學習者的基礎學習
- 困難詞彙(學習者程度 < 詞彙程度)的重新熟悉
2. 選擇題 (Multiple Choice)
- 操作方式: 給定義,選擇正確的詞彙
- 學習效益: 加深詞彙定義與詞彙之間的連結
- 適用情境:
- A1學習者的概念建立
- 困難詞彙的定義強化
3. 詞彙聽力題 (Vocabulary Listening)
- 操作方式: 播放詞彙發音,選擇正確詞彙
- 學習效益: 加強詞彙的發音記憶
- 限制說明: 人類短期記憶能力強,當次學習時聽力複習由短期記憶驅動,可能壓縮發音與詞彙本身的連結效果
- 適用情境: A1學習者的發音熟悉
4. 例句聽力題 (Sentence Listening)
- 操作方式: 播放例句,選擇正確例句
- 學習效益: 強化例句的發音記憶
- 限制說明: 受短期記憶影響,對學習新例句幫助有限
- 適用情境: 長期複習中的聽力維持
5. 填空題 (Fill in the Blank)
- 操作方式: 提供挖空例句,學習者填入正確詞彙
- 學習效益:
- 練習拼字能力
- 加深詞彙與使用情境的連結
- 適用情境:
- 簡單詞彙(學習者程度 > 詞彙程度)
- 適中詞彙(學習者程度 = 詞彙程度)
6. 例句重組題 (Sentence Reconstruction)
- 操作方式: 打亂例句單字順序,重新組織成完整句子
- 學習效益: 快速練習組織句子的能力
- 適用情境:
- 簡單詞彙的語法練習
- 適中詞彙的句型熟悉
7. 例句口說題 (Sentence Speaking)
- 操作方式: 給出例句,學習者朗讀例句
- 學習效益:
- 練習看圖揣摩情境
- 練習完整句子表達
- 加深例句與情境的連結
- 模仿母語者表達方式
- 適用情境: 適中詞彙的口語表達練習
學習程度適配策略
A1初學者策略
const A1_REVIEW_TYPES = ['flipcard', 'vocabulary_listening', 'multiple_choice'];
// 統一使用基礎題型,重點建立信心和基本概念
function getA1ReviewType(flashcard: Flashcard): ReviewType {
// 隨機選擇基礎題型,或根據上次表現調整
const weights = {
flipcard: 0.4, // 40% - 主要熟悉方式
multiple_choice: 0.4, // 40% - 概念強化
vocabulary_listening: 0.2 // 20% - 發音熟悉
};
return weightedRandomSelect(A1_REVIEW_TYPES, weights);
}
程度適配算法
interface DifficultyMapping {
userLevel: number; // 學習者程度 (1-100)
wordLevel: number; // 詞彙難度 (1-100)
reviewTypes: ReviewType[];
}
function getReviewTypesByDifficulty(userLevel: number, wordLevel: number): ReviewType[] {
const difficulty = wordLevel - userLevel;
if (userLevel <= 20) {
// A1學習者 - 統一基礎題型
return ['flipcard', 'multiple_choice', 'vocabulary_listening'];
} else if (difficulty < -10) {
// 簡單詞彙 (學習者程度 > 詞彙程度)
return ['sentence_reconstruction', 'fill_blank'];
} else if (difficulty >= -10 && difficulty <= 10) {
// 適中詞彙 (學習者程度 ≈ 詞彙程度)
return ['fill_blank', 'sentence_reconstruction', 'sentence_speaking'];
} else {
// 困難詞彙 (學習者程度 < 詞彙程度)
return ['flipcard', 'multiple_choice'];
}
}
複習模式組件設計
ReviewModeSelector 組件
interface ReviewModeProps {
availableModes: ReviewType[];
currentMode: ReviewType;
onModeChange: (mode: ReviewType) => void;
}
export const ReviewModeSelector: React.FC<ReviewModeProps> = ({
availableModes,
currentMode,
onModeChange
}) => {
const modeLabels = {
flipcard: '翻卡題',
multiple_choice: '選擇題',
vocabulary_listening: '詞彙聽力',
sentence_listening: '例句聽力',
fill_blank: '填空題',
sentence_reconstruction: '例句重組',
sentence_speaking: '例句口說'
};
return (
<div className="review-mode-selector">
<h3>複習方式</h3>
<div className="mode-buttons">
{availableModes.map(mode => (
<button
key={mode}
className={`mode-btn ${currentMode === mode ? 'active' : ''}`}
onClick={() => onModeChange(mode)}
>
{modeLabels[mode]}
</button>
))}
</div>
</div>
);
};
更新的狀態管理
interface ReviewState {
currentCard: Flashcard | null;
showAnswer: boolean;
reviewMode: ReviewType;
availableReviewModes: ReviewType[];
confidenceLevel: number | null;
userAnswer: string | null;
isCorrect: boolean | null;
isSubmitting: boolean;
currentQuestionData: QuestionData | null;
}
interface QuestionData {
questionType: ReviewType;
options?: string[]; // 選擇題選項
correctAnswer: string; // 正確答案
userLevel: number; // 學習者程度
wordLevel: number; // 詞彙難度
audioUrl?: string; // 聽力題音頻
sentence?: string; // 例句
blankedSentence?: string; // 填空題的挖空句子
scrambledWords?: string[]; // 重組題的打亂單字
}
export const ReviewPage: React.FC = () => {
const [state, setState] = useState<ReviewState>({
currentCard: null,
showAnswer: false,
reviewMode: 'flipcard',
availableReviewModes: [],
confidenceLevel: null,
userAnswer: null,
isCorrect: null,
isSubmitting: false,
currentQuestionData: null
});
useEffect(() => {
loadNextCard();
}, []);
const loadNextCard = async () => {
try {
const response = await reviewApi.getNextReviewCard();
const card = response.data;
// 根據學習者程度和詞彙難度決定可用的複習模式
const availableModes = getReviewTypesByDifficulty(
card.userLevel,
card.wordLevel
);
// 選擇當前複習模式
const selectedMode = selectReviewMode(availableModes, card);
// 生成題目數據
const questionData = await generateQuestionData(card, selectedMode);
setState(prev => ({
...prev,
currentCard: card,
availableReviewModes: availableModes,
reviewMode: selectedMode,
currentQuestionData: questionData,
showAnswer: false,
userAnswer: null,
isCorrect: null
}));
} catch (error) {
console.error('載入卡片失敗:', error);
}
};
const handleAnswerSubmit = async (userAnswer: string | boolean) => {
if (!state.currentCard || !state.currentQuestionData) return;
setState(prev => ({ ...prev, isSubmitting: true }));
try {
// 檢查答案正確性
const isCorrect = checkAnswer(userAnswer, state.currentQuestionData);
setState(prev => ({
...prev,
userAnswer: typeof userAnswer === 'string' ? userAnswer : null,
isCorrect,
showAnswer: true
}));
// 提交復習結果
const result = await reviewApi.submitReview(state.currentCard.id, {
isCorrect,
confidenceLevel: state.confidenceLevel,
questionType: state.reviewMode,
userAnswer: typeof userAnswer === 'string' ? userAnswer : null,
timeTaken: Date.now() - state.startTime
});
// 顯示結果反饋
showFeedback(result.data);
} catch (error) {
console.error('復習提交失敗:', error);
} finally {
setState(prev => ({ ...prev, isSubmitting: false }));
}
};
const renderQuestionComponent = () => {
if (!state.currentCard || !state.currentQuestionData) return null;
const commonProps = {
flashcard: state.currentCard,
questionData: state.currentQuestionData,
onAnswerSubmit: handleAnswerSubmit,
isSubmitting: state.isSubmitting,
showResult: state.showAnswer,
isCorrect: state.isCorrect,
userAnswer: state.userAnswer
};
switch (state.reviewMode) {
case 'flipcard':
return <FlipCardQuestion {...commonProps} />;
case 'multiple_choice':
return <MultipleChoiceQuestion {...commonProps} />;
case 'vocabulary_listening':
return <VocabularyListeningQuestion {...commonProps} />;
case 'sentence_listening':
return <SentenceListeningQuestion {...commonProps} />;
case 'fill_blank':
return <FillBlankQuestion {...commonProps} />;
case 'sentence_reconstruction':
return <SentenceReconstructionQuestion {...commonProps} />;
case 'sentence_speaking':
return <SentenceSpeakingQuestion {...commonProps} />;
default:
return <FlipCardQuestion {...commonProps} />;
}
};
return (
<div className="review-page">
{state.currentCard && (
<>
<ReviewModeSelector
availableModes={state.availableReviewModes}
currentMode={state.reviewMode}
onModeChange={(mode) => {
// 切換複習模式時重新生成題目
generateQuestionData(state.currentCard, mode).then(questionData => {
setState(prev => ({
...prev,
reviewMode: mode,
currentQuestionData: questionData,
showAnswer: false,
userAnswer: null,
isCorrect: null
}));
});
}}
/>
{renderQuestionComponent()}
{state.showAnswer && (
<div className="next-card-section">
<button
className="next-btn"
onClick={loadNextCard}
disabled={state.isSubmitting}
>
下一張卡片
</button>
</div>
)}
</>
)}
</div>
);
};
### **各種題型組件實現**
#### **1. FlipCardQuestion 組件**
```tsx
interface QuestionProps {
flashcard: Flashcard;
questionData: QuestionData;
onAnswerSubmit: (answer: boolean) => void;
isSubmitting: boolean;
showResult: boolean;
isCorrect: boolean | null;
}
export const FlipCardQuestion: React.FC<QuestionProps> = ({
flashcard,
onAnswerSubmit,
isSubmitting,
showResult
}) => {
const [showDefinition, setShowDefinition] = useState(false);
return (
<div className="flip-card-question">
<div className="card-content">
<h2 className="word">{flashcard.word}</h2>
{showDefinition && (
<div className="definition-section">
<p className="definition">{flashcard.definition}</p>
{flashcard.example && (
<p className="example">例句:{flashcard.example}</p>
)}
</div>
)}
</div>
{!showDefinition ? (
<button
className="show-answer-btn"
onClick={() => setShowDefinition(true)}
>
顯示答案
</button>
) : (
<div className="confidence-rating">
<h3>您對這個詞彙的熟悉程度如何?</h3>
<div className="confidence-buttons">
<button
onClick={() => onAnswerSubmit(false)}
disabled={isSubmitting}
className="confidence-btn not-familiar"
>
不熟悉
</button>
<button
onClick={() => onAnswerSubmit(true)}
disabled={isSubmitting}
className="confidence-btn familiar"
>
熟悉
</button>
</div>
</div>
)}
</div>
);
};
2. MultipleChoiceQuestion 組件
export const MultipleChoiceQuestion: React.FC<QuestionProps> = ({
flashcard,
questionData,
onAnswerSubmit,
isSubmitting,
showResult,
isCorrect,
userAnswer
}) => {
const [selectedOption, setSelectedOption] = useState<string | null>(null);
const handleOptionSelect = (option: string) => {
if (showResult) return;
setSelectedOption(option);
onAnswerSubmit(option);
};
return (
<div className="multiple-choice-question">
<div className="question-text">
<h3>請選擇正確的詞彙:</h3>
<p className="definition">{flashcard.definition}</p>
</div>
<div className="options">
{questionData.options?.map((option, index) => (
<button
key={index}
className={`option-btn ${
showResult
? option === questionData.correctAnswer
? 'correct'
: option === userAnswer
? 'incorrect'
: ''
: selectedOption === option
? 'selected'
: ''
}`}
onClick={() => handleOptionSelect(option)}
disabled={isSubmitting || showResult}
>
{option}
</button>
))}
</div>
{showResult && (
<div className={`result-feedback ${isCorrect ? 'correct' : 'incorrect'}`}>
{isCorrect ? '✓ 答對了!' : `✗ 正確答案是:${questionData.correctAnswer}`}
</div>
)}
</div>
);
};
3. FillBlankQuestion 組件
export const FillBlankQuestion: React.FC<QuestionProps> = ({
flashcard,
questionData,
onAnswerSubmit,
isSubmitting,
showResult,
isCorrect,
userAnswer
}) => {
const [inputValue, setInputValue] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (inputValue.trim()) {
onAnswerSubmit(inputValue.trim());
}
};
return (
<div className="fill-blank-question">
<div className="question-text">
<h3>請填入正確的詞彙:</h3>
<p className="sentence">
{questionData.blankedSentence?.split('___').map((part, index) => (
<span key={index}>
{part}
{index < questionData.blankedSentence!.split('___').length - 1 && (
<span className="blank-space">
{showResult ? (
<span className={`filled-answer ${isCorrect ? 'correct' : 'incorrect'}`}>
{userAnswer || '___'}
</span>
) : (
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
className="blank-input"
disabled={isSubmitting}
placeholder="___"
/>
)}
</span>
)}
</span>
))}
</p>
</div>
{!showResult && (
<form onSubmit={handleSubmit}>
<button
type="submit"
disabled={!inputValue.trim() || isSubmitting}
className="submit-btn"
>
提交答案
</button>
</form>
)}
{showResult && (
<div className={`result-feedback ${isCorrect ? 'correct' : 'incorrect'}`}>
{isCorrect ? (
'✓ 答對了!'
) : (
<div>
<p>✗ 您的答案:{userAnswer}</p>
<p>正確答案:{questionData.correctAnswer}</p>
</div>
)}
</div>
)}
</div>
);
};
4. SentenceReconstructionQuestion 組件
export const SentenceReconstructionQuestion: React.FC<QuestionProps> = ({
flashcard,
questionData,
onAnswerSubmit,
isSubmitting,
showResult,
isCorrect,
userAnswer
}) => {
const [selectedWords, setSelectedWords] = useState<string[]>([]);
const [availableWords, setAvailableWords] = useState<string[]>(
questionData.scrambledWords || []
);
const handleWordClick = (word: string, isFromSelected: boolean) => {
if (showResult) return;
if (isFromSelected) {
// 從已選擇移回可選擇
setSelectedWords(prev => prev.filter(w => w !== word));
setAvailableWords(prev => [...prev, word]);
} else {
// 從可選擇移到已選擇
setSelectedWords(prev => [...prev, word]);
setAvailableWords(prev => prev.filter(w => w !== word));
}
};
const handleSubmit = () => {
const reconstructedSentence = selectedWords.join(' ');
onAnswerSubmit(reconstructedSentence);
};
return (
<div className="sentence-reconstruction-question">
<div className="question-text">
<h3>請重新組織以下單字成為正確的句子:</h3>
<p className="hint">目標詞彙:<strong>{flashcard.word}</strong></p>
</div>
<div className="word-construction-area">
<div className="selected-words">
<h4>您的句子:</h4>
<div className="word-container">
{selectedWords.map((word, index) => (
<button
key={`selected-${index}`}
className="word-btn selected"
onClick={() => handleWordClick(word, true)}
disabled={showResult}
>
{word}
</button>
))}
</div>
</div>
<div className="available-words">
<h4>可用單字:</h4>
<div className="word-container">
{availableWords.map((word, index) => (
<button
key={`available-${index}`}
className="word-btn available"
onClick={() => handleWordClick(word, false)}
disabled={showResult}
>
{word}
</button>
))}
</div>
</div>
</div>
{!showResult && (
<button
onClick={handleSubmit}
disabled={selectedWords.length === 0 || isSubmitting}
className="submit-btn"
>
提交答案
</button>
)}
{showResult && (
<div className={`result-feedback ${isCorrect ? 'correct' : 'incorrect'}`}>
{isCorrect ? (
'✓ 答對了!'
) : (
<div>
<p>✗ 您的答案:{userAnswer}</p>
<p>正確答案:{questionData.correctAnswer}</p>
</div>
)}
</div>
)}
</div>
);
};
5. VocabularyListeningQuestion 組件
export const VocabularyListeningQuestion: React.FC<QuestionProps> = ({
flashcard,
questionData,
onAnswerSubmit,
isSubmitting,
showResult,
isCorrect,
userAnswer
}) => {
const audioRef = useRef<HTMLAudioElement>(null);
const [selectedOption, setSelectedOption] = useState<string | null>(null);
const playAudio = () => {
if (audioRef.current) {
audioRef.current.play();
}
};
const handleOptionSelect = (option: string) => {
if (showResult) return;
setSelectedOption(option);
onAnswerSubmit(option);
};
return (
<div className="vocabulary-listening-question">
<div className="question-text">
<h3>請聽音頻並選擇正確的詞彙:</h3>
</div>
<div className="audio-section">
<audio ref={audioRef} src={questionData.audioUrl} preload="auto" />
<button className="play-btn" onClick={playAudio}>
🔊 播放音頻
</button>
</div>
<div className="options">
{questionData.options?.map((option, index) => (
<button
key={index}
className={`option-btn ${
showResult
? option === questionData.correctAnswer
? 'correct'
: option === userAnswer
? 'incorrect'
: ''
: selectedOption === option
? 'selected'
: ''
}`}
onClick={() => handleOptionSelect(option)}
disabled={isSubmitting || showResult}
>
{option}
</button>
))}
</div>
{showResult && (
<div className={`result-feedback ${isCorrect ? 'correct' : 'incorrect'}`}>
{isCorrect ? '✓ 答對了!' : `✗ 正確答案是:${questionData.correctAnswer}`}
<div className="definition-reveal">
<p><strong>定義:</strong>{flashcard.definition}</p>
</div>
</div>
)}
</div>
);
};
6. SentenceSpeakingQuestion 組件
export const SentenceSpeakingQuestion: React.FC<QuestionProps> = ({
flashcard,
questionData,
onAnswerSubmit,
isSubmitting,
showResult,
isCorrect
}) => {
const [isRecording, setIsRecording] = useState(false);
const [hasRecorded, setHasRecorded] = useState(false);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null);
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorderRef.current = new MediaRecorder(stream);
const chunks: Blob[] = [];
mediaRecorderRef.current.ondataavailable = (event) => {
chunks.push(event.data);
};
mediaRecorderRef.current.onstop = () => {
const blob = new Blob(chunks, { type: 'audio/wav' });
setRecordedBlob(blob);
setHasRecorded(true);
};
mediaRecorderRef.current.start();
setIsRecording(true);
} catch (error) {
console.error('錄音失敗:', error);
}
};
const stopRecording = () => {
if (mediaRecorderRef.current && isRecording) {
mediaRecorderRef.current.stop();
setIsRecording(false);
// 停止所有音頻軌道
const tracks = mediaRecorderRef.current.stream?.getTracks();
tracks?.forEach(track => track.stop());
}
};
const playRecording = () => {
if (recordedBlob) {
const audio = new Audio(URL.createObjectURL(recordedBlob));
audio.play();
}
};
const submitRecording = () => {
// 這裡可以上傳音頻到服務器進行語音識別
// 暫時簡化為自我評估
onAnswerSubmit(true);
};
return (
<div className="sentence-speaking-question">
<div className="question-text">
<h3>請大聲朗讀以下例句:</h3>
<div className="sentence-display">
{questionData.sentence && (
<p className="sentence">{questionData.sentence}</p>
)}
</div>
<div className="word-highlight">
<p>重點詞彙:<strong>{flashcard.word}</strong></p>
<p>定義:{flashcard.definition}</p>
</div>
</div>
<div className="recording-section">
{!hasRecorded && !showResult && (
<div className="recording-controls">
<button
className={`record-btn ${isRecording ? 'recording' : ''}`}
onClick={isRecording ? stopRecording : startRecording}
disabled={isSubmitting}
>
{isRecording ? '🔴 停止錄音' : '🎤 開始錄音'}
</button>
</div>
)}
{hasRecorded && !showResult && (
<div className="playback-controls">
<button className="play-btn" onClick={playRecording}>
🔊 播放錄音
</button>
<button className="submit-btn" onClick={submitRecording}>
提交錄音
</button>
<button className="retry-btn" onClick={() => {
setHasRecorded(false);
setRecordedBlob(null);
}}>
重新錄音
</button>
</div>
)}
</div>
{showResult && (
<div className="result-feedback correct">
<p>✓ 很好!您已完成口說練習</p>
<div className="speaking-tips">
<p>💡 注意發音要點:</p>
<ul>
<li>重音位置和語調變化</li>
<li>詞彙在句子中的自然表達</li>
<li>整句話的流暢度</li>
</ul>
</div>
</div>
)}
</div>
);
};
🔌 API 整合
服務層設計
reviewApi.js
class ReviewAPI {
async getDueFlashcards(limit = 50) {
const today = new Date().toISOString().split('T')[0];
return await fetch(`/api/flashcards/due?date=${today}&limit=${limit}`);
}
async getNextReviewCard() {
return await fetch('/api/flashcards/next-review');
}
async getFlashcard(id) {
return await fetch(`/api/flashcards/${id}`);
}
async submitReview(id, reviewData) {
return await fetch(`/api/flashcards/${id}/review`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...reviewData,
timestamp: Date.now()
}),
});
}
async generateQuestion(cardId, questionType) {
return await fetch(`/api/flashcards/${cardId}/question`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ questionType }),
});
}
async getBatchFlashcards(ids) {
const idsParam = ids.join(',');
return await fetch(`/api/flashcards/batch?ids=${idsParam}`);
}
async uploadAudio(cardId, audioBlob) {
const formData = new FormData();
formData.append('audio', audioBlob, 'recording.wav');
formData.append('cardId', cardId);
return await fetch('/api/flashcards/audio/upload', {
method: 'POST',
body: formData,
});
}
async getAvailableReviewModes(cardId, userLevel) {
return await fetch(`/api/flashcards/${cardId}/review-modes?userLevel=${userLevel}`);
}
}
export const reviewApi = new ReviewAPI();
masteryCalculator.js
// 前端實時計算當前熟悉度(與後端邏輯一致)
export function calculateCurrentMastery(baseMastery, lastReviewDate) {
const today = new Date();
const lastDate = new Date(lastReviewDate);
const daysSince = Math.floor((today - lastDate) / (1000 * 60 * 60 * 24));
if (daysSince <= 0) return baseMastery;
// 應用記憶衰減(與後端一致的算法)
const decayRate = 0.05; // 每天5%衰減
const maxDecayDays = 30;
const effectiveDays = Math.min(daysSince, maxDecayDays);
const decayFactor = Math.pow(1 - decayRate, effectiveDays);
return Math.max(0, Math.floor(baseMastery * decayFactor));
}
// 計算衰減程度
export function getDecayAmount(baseMastery, currentMastery) {
return Math.max(0, baseMastery - currentMastery);
}
// 複習方式選擇邏輯
export function getReviewTypesByDifficulty(userLevel, wordLevel) {
const difficulty = wordLevel - userLevel;
if (userLevel <= 20) {
// A1學習者 - 統一基礎題型
return ['flipcard', 'multiple_choice', 'vocabulary_listening'];
} else if (difficulty < -10) {
// 簡單詞彙 (學習者程度 > 詞彙程度)
return ['sentence_reconstruction', 'fill_blank'];
} else if (difficulty >= -10 && difficulty <= 10) {
// 適中詞彙 (學習者程度 ≈ 詞彙程度)
return ['fill_blank', 'sentence_reconstruction', 'sentence_speaking'];
} else {
// 困難詞彙 (學習者程度 < 詞彙程度)
return ['flipcard', 'multiple_choice'];
}
}
// 選擇當前複習模式
export function selectReviewMode(availableModes, flashcard, reviewHistory = []) {
// 基於最近使用的模式和表現來選擇
const recentModes = reviewHistory.slice(-3).map(r => r.questionType);
// 避免連續使用相同模式超過2次
const lastMode = recentModes[recentModes.length - 1];
const consecutiveCount = recentModes.reverse().findIndex(mode => mode !== lastMode);
if (consecutiveCount >= 2) {
return availableModes.find(mode => mode !== lastMode) || availableModes[0];
}
// A1學習者權重分配
if (flashcard.userLevel <= 20) {
const weights = {
flipcard: 0.4,
multiple_choice: 0.4,
vocabulary_listening: 0.2
};
return weightedRandomSelect(availableModes, weights);
}
// 其他情況隨機選擇
return availableModes[Math.floor(Math.random() * availableModes.length)];
}
// 權重隨機選擇
function weightedRandomSelect(items, weights) {
const totalWeight = Object.values(weights).reduce((sum, weight) => sum + weight, 0);
let randomNum = Math.random() * totalWeight;
for (const item of items) {
randomNum -= weights[item] || (1 / items.length);
if (randomNum <= 0) {
return item;
}
}
return items[0];
}
// 生成題目數據
export async function generateQuestionData(flashcard, questionType) {
const response = await reviewApi.generateQuestion(flashcard.id, questionType);
return response.data;
}
// 檢查答案正確性
export function checkAnswer(userAnswer, questionData) {
if (typeof userAnswer === 'boolean') {
// 翻卡題或口說題的自我評估
return userAnswer;
}
// 字符串比較答案
const userAnswerNormalized = userAnswer.toString().trim().toLowerCase();
const correctAnswerNormalized = questionData.correctAnswer.trim().toLowerCase();
return userAnswerNormalized === correctAnswerNormalized;
}
🎨 UI/UX 設計規範
色彩設計
:root {
/* 熟悉度顏色 */
--mastery-high: #34c759; /* 綠色 80-100% */
--mastery-medium: #007aff; /* 藍色 50-79% */
--mastery-low: #ff3b30; /* 紅色 0-49% */
--mastery-decaying: #ff9500; /* 橙色 衰減中 */
/* 復習狀態顏色 */
--status-due: #007aff; /* 到期 */
--status-overdue: #ff3b30; /* 逾期 */
--status-future: #8e8e93; /* 未到期 */
/* 背景色 */
--bg-primary: #ffffff;
--bg-secondary: #f2f2f7;
--bg-tertiary: #e5e5ea;
}
響應式設計
/* 手機端 */
@media (max-width: 768px) {
.flashcard-item {
padding: 12px;
margin: 8px 0;
}
.mastery-indicator.medium {
width: 40px;
height: 40px;
}
}
/* 平板端 */
@media (min-width: 769px) and (max-width: 1024px) {
.card-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
}
/* 桌面端 */
@media (min-width: 1025px) {
.card-list {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
}
動畫效果
.mastery-indicator .progress-circle circle {
transition: stroke-dasharray 0.6s ease-in-out;
}
.flashcard-item {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.flashcard-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.decay-icon {
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
📊 狀態管理
使用 React Context
interface SpacedRepetitionContextValue {
flashcards: Flashcard[];
dueCount: number;
completedToday: number;
refreshFlashcards: () => Promise<void>;
updateFlashcard: (id: number, updates: Partial<Flashcard>) => void;
}
const SpacedRepetitionContext = createContext<SpacedRepetitionContextValue | null>(null);
export const SpacedRepetitionProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [flashcards, setFlashcards] = useState<Flashcard[]>([]);
const [dueCount, setDueCount] = useState(0);
const [completedToday, setCompletedToday] = useState(0);
const refreshFlashcards = async () => {
const response = await reviewApi.getAllFlashcards();
setFlashcards(response.data);
// 計算到期數量
const today = new Date().toISOString().split('T')[0];
const due = response.data.filter(card => card.nextReviewDate <= today).length;
setDueCount(due);
};
return (
<SpacedRepetitionContext.Provider value={{
flashcards,
dueCount,
completedToday,
refreshFlashcards,
updateFlashcard
}}>
{children}
</SpacedRepetitionContext.Provider>
);
};
🧪 測試策略
單元測試
// masteryCalculator.test.js
describe('calculateCurrentMastery', () => {
test('should return base mastery for same day', () => {
const today = new Date().toISOString().split('T')[0];
const result = calculateCurrentMastery(80, today);
expect(result).toBe(80);
});
test('should apply decay for overdue cards', () => {
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
.toISOString().split('T')[0];
const result = calculateCurrentMastery(80, sevenDaysAgo);
expect(result).toBeLessThan(80);
expect(result).toBeGreaterThan(50);
});
});
整合測試
- API 呼叫測試
- 組件互動測試
- 狀態更新測試
E2E 測試
- 復習流程測試
- 熟悉度更新測試
- 響應式設計測試
📋 開發檢查清單
功能實現
- FlashcardItem 組件完成
- MasteryIndicator 組件完成
- ReviewSchedule 組件完成
- ReviewPage 組件完成
- API 整合完成
UI/UX
- 響應式設計實現
- 動畫效果添加
- 色彩規範應用
- 無障礙支援
性能優化
- 組件懶加載
- API 請求優化
- 記憶體洩漏檢查
- 打包大小優化
測試
- 單元測試 > 80% 覆蓋率
- 整合測試通過
- E2E 測試通過
- 性能測試通過
開發時間: 3-4個工作日 測試時間: 1-2個工作日 上線準備: 響應式測試、瀏覽器相容性測試