dramaling-vocab-learning/note/智能複習/智能複習系統-前端功能規格書.md

40 KiB
Raw Blame History

智能複習系統 - 前端功能規格書 (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'];
  }
}

複習題型顯示組件設計

ReviewTypeIndicator 組件

interface ReviewTypeIndicatorProps {
  currentMode: ReviewType;
  userLevel: number;
  wordLevel: number;
}

export const ReviewTypeIndicator: React.FC<ReviewTypeIndicatorProps> = ({
  currentMode,
  userLevel,
  wordLevel
}) => {
  const modeLabels = {
    flipcard: '翻卡題',
    multiple_choice: '選擇題',
    vocabulary_listening: '詞彙聽力',
    sentence_listening: '例句聽力',
    fill_blank: '填空題',
    sentence_reconstruction: '例句重組',
    sentence_speaking: '例句口說'
  };

  const getDifficultyLabel = (userLevel: number, wordLevel: number) => {
    const difficulty = wordLevel - userLevel;
    if (userLevel <= 20) return 'A1學習者';
    if (difficulty < -10) return '簡單詞彙';
    if (difficulty >= -10 && difficulty <= 10) return '適中詞彙';
    return '困難詞彙';
  };

  return (
    <div className="review-type-indicator">
      <div className="current-type">
        <span className="type-label">{modeLabels[currentMode]}</span>
        <span className="auto-selected">系統智能選擇</span>
      </div>
      <div className="difficulty-info">
        <span className="difficulty-label">
          {getDifficultyLabel(userLevel, wordLevel)}適配
        </span>
      </div>
    </div>
  );
};

更新的狀態管理

interface ReviewState {
  currentCard: Flashcard | null;
  showAnswer: boolean;
  reviewMode: ReviewType;              // 系統自動選擇的題型
  confidenceLevel: number | null;
  userAnswer: string | null;
  isCorrect: boolean | null;
  isSubmitting: boolean;
  currentQuestionData: QuestionData | null;
  startTime: number;                   // 題目開始時間
}

// 移除 availableReviewModes因為用戶不需要選擇

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',
    confidenceLevel: null,
    userAnswer: null,
    isCorrect: null,
    isSubmitting: false,
    currentQuestionData: null,
    startTime: Date.now()
  });

  useEffect(() => {
    loadNextCard();
  }, []);

  const loadNextCard = async () => {
    try {
      const response = await reviewApi.getNextReviewCard();
      const card = response.data;

      // 系統自動選擇最適合的複習模式
      const selectedMode = await selectOptimalReviewMode(card);

      // 生成對應的題目數據
      const questionData = await generateQuestionData(card, selectedMode);

      setState(prev => ({
        ...prev,
        currentCard: card,
        reviewMode: selectedMode,
        currentQuestionData: questionData,
        showAnswer: false,
        userAnswer: null,
        isCorrect: null,
        startTime: Date.now()
      }));
    } catch (error) {
      console.error('載入卡片失敗:', error);
    }
  };

  // 新增:系統自動選擇題型的函數
  const selectOptimalReviewMode = async (card: Flashcard): Promise<ReviewType> => {
    const response = await reviewApi.getOptimalReviewMode(card.id, card.userLevel, card.wordLevel);
    return response.data.selectedMode;
  };

  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 && (
        <>
          <ReviewTypeIndicator
            currentMode={state.reviewMode}
            userLevel={state.currentCard.userLevel}
            wordLevel={state.currentCard.wordLevel}
          />

          {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 getOptimalReviewMode(cardId, userLevel, wordLevel) {
    return await fetch(`/api/flashcards/${cardId}/optimal-review-mode`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        userLevel,
        wordLevel,
        includeHistory: true  // 包含歷史記錄以避免重複
      }),
    });
  }
}

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 getExpectedReviewMode(userLevel, wordLevel, reviewHistory = []) {
  // 這個函數主要用於前端預測實際選擇由後端API決定
  const availableModes = getReviewTypesByDifficulty(userLevel, wordLevel);

  // 簡化的前端邏輯,與後端保持一致
  if (userLevel <= 20) {
    // A1學習者在基礎題型中輪換
    const basicModes = ['flipcard', 'multiple_choice', 'vocabulary_listening'];
    return basicModes[Math.floor(Math.random() * basicModes.length)];
  }

  // 其他情況返回第一個可用題型作為預期
  return availableModes[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個工作日 上線準備: 響應式測試、瀏覽器相容性測試