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

20 KiB
Raw Blame History

複習系統前端規格書

版本: 1.0 對應: 技術實作規格.md + 產品需求規格.md 技術棧: React 18 + TypeScript + Tailwind CSS 狀態管理: React useState (極簡架構) 最後更新: 2025-10-03


📱 前端架構設計

目錄結構

app/review-simple/
├── page.tsx                    # 主頁面邏輯
├── data.ts                     # 數據和類型定義
├── globals.css                 # 翻卡動畫樣式
└── components/
    ├── SimpleFlipCard.tsx      # 翻卡記憶組件
    ├── SimpleChoiceTest.tsx    # 詞彙選擇組件 (階段2)
    ├── SimpleProgress.tsx      # 進度顯示組件
    ├── SimpleResults.tsx       # 結果統計組件
    └── SimpleTestHeader.tsx    # 測試標題組件

🗃️ 數據結構設計

卡片狀態接口

interface CardState extends ApiFlashcard {
  // 前端狀態管理欄位
  skipCount: number        // 跳過次數
  wrongCount: number       // 答錯次數
  successCount: number     // 答對次數
  isCompleted: boolean     // 是否已完成
  originalOrder: number    // 原始順序索引

  // 計算屬性
  delayScore: number       // 延遲分數 = skipCount + wrongCount
  lastAttemptAt: Date     // 最後嘗試時間
}

學習會話狀態

interface ReviewSessionState {
  // 卡片管理
  cards: CardState[]
  currentIndex: number

  // 進度統計
  score: {
    correct: number        // 答對總數
    total: number         // 嘗試總數
  }

  // 會話控制
  isComplete: boolean
  startTime: Date

  // UI狀態
  currentMode: 'flip' | 'choice' // 階段2需要
}

⚙️ 核心邏輯函數

延遲計數管理

// 跳過處理
const handleSkip = useCallback((cards: CardState[], currentIndex: number) => {
  const updatedCards = cards.map((card, index) =>
    index === currentIndex
      ? {
          ...card,
          skipCount: card.skipCount + 1,
          delayScore: card.skipCount + 1 + card.wrongCount,
          lastAttemptAt: new Date()
        }
      : card
  )

  return {
    updatedCards,
    nextIndex: getNextCardIndex(updatedCards, currentIndex)
  }
}, [])

// 答錯處理
const handleWrongAnswer = useCallback((cards: CardState[], currentIndex: number) => {
  const updatedCards = cards.map((card, index) =>
    index === currentIndex
      ? {
          ...card,
          wrongCount: card.wrongCount + 1,
          delayScore: card.skipCount + card.wrongCount + 1,
          lastAttemptAt: new Date()
        }
      : card
  )

  return {
    updatedCards,
    nextIndex: getNextCardIndex(updatedCards, currentIndex)
  }
}, [])

// 答對處理
const handleCorrectAnswer = useCallback((cards: CardState[], currentIndex: number) => {
  const updatedCards = cards.map((card, index) =>
    index === currentIndex
      ? {
          ...card,
          isCompleted: true,
          successCount: card.successCount + 1,
          lastAttemptAt: new Date()
        }
      : card
  )

  return {
    updatedCards,
    nextIndex: getNextCardIndex(updatedCards, currentIndex)
  }
}, [])

智能排序系統

// 卡片優先級排序 (您的核心需求)
const sortCardsByPriority = useCallback((cards: CardState[]): CardState[] => {
  return cards.sort((a, b) => {
    // 1. 已完成的卡片排到最後
    if (a.isCompleted && !b.isCompleted) return 1
    if (!a.isCompleted && b.isCompleted) return -1

    // 2. 未完成卡片按延遲分數排序 (越少越前面)
    const aDelayScore = a.skipCount + a.wrongCount
    const bDelayScore = b.skipCount + b.wrongCount

    if (aDelayScore !== bDelayScore) {
      return aDelayScore - bDelayScore
    }

    // 3. 延遲分數相同時按原始順序
    return a.originalOrder - b.originalOrder
  })
}, [])

// 獲取下一張卡片索引
const getNextCardIndex = (cards: CardState[], currentIndex: number): number => {
  const sortedCards = sortCardsByPriority(cards)
  const incompleteCards = sortedCards.filter(card => !card.isCompleted)

  if (incompleteCards.length === 0) return -1 // 全部完成

  // 返回排序後第一張未完成卡片的索引
  const nextCard = incompleteCards[0]
  return cards.findIndex(card => card.id === nextCard.id)
}

🎯 組件設計規格

SimpleFlipCard.tsx (階段1)

interface SimpleFlipCardProps {
  card: CardState
  onAnswer: (confidence: 1|2|3) => void  // 簡化為3選項
  onSkip: () => void
}

// 內部狀態
const [isFlipped, setIsFlipped] = useState(false)
const [selectedConfidence, setSelectedConfidence] = useState<number | null>(null)
const [cardHeight, setCardHeight] = useState<number>(400)

// 信心度選項 (簡化版)
const confidenceOptions = [
  { level: 1, label: '模糊', color: 'bg-orange-100 text-orange-700 border-orange-200' },
  { level: 2, label: '一般', color: 'bg-yellow-100 text-yellow-700 border-yellow-200' },
  { level: 3, label: '熟悉', color: 'bg-green-100 text-green-700 border-green-200' }
]

SimpleChoiceTest.tsx (階段2)

interface SimpleChoiceTestProps {
  card: CardState
  options: string[]              // 4選1選項
  onAnswer: (answer: string) => void
  onSkip: () => void
}

// 內部狀態
const [selectedAnswer, setSelectedAnswer] = useState<string | null>(null)
const [showResult, setShowResult] = useState(false)

// 答案驗證
const isCorrect = useMemo(() => selectedAnswer === card.word, [selectedAnswer, card.word])

SimpleProgress.tsx

interface SimpleProgressProps {
  cards: CardState[]
  currentIndex: number
  score: { correct: number; total: number }
}

// 進度計算
const completedCount = cards.filter(card => card.isCompleted).length
const totalCount = cards.length
const progressPercentage = (completedCount / totalCount) * 100

// 延遲統計 (顯示跳過次數)
const delayedCards = cards.filter(card => card.skipCount + card.wrongCount > 0)
const totalSkips = cards.reduce((sum, card) => sum + card.skipCount, 0)
const totalWrongs = cards.reduce((sum, card) => sum + card.wrongCount, 0)

🌐 API呼叫策略 (各階段明確區分)

階段1: 純靜態數據 (當前MVP)

// 完全不呼叫任何API
export default function SimpleReviewPage() {
  useEffect(() => {
    // 直接使用靜態數據,無網路依賴
    const staticCards = SIMPLE_CARDS.map((card, index) => ({
      ...card,
      skipCount: 0,
      wrongCount: 0,
      // ... 其他前端狀態
    }))
    setCards(staticCards)
  }, [])

  // 答題完成時只更新前端狀態不呼叫API
  const handleAnswer = (confidence: number) => {
    // 純前端邏輯無API調用
    updateLocalState(confidence)
  }
}

階段2: 本地持久化 (localStorage)

// 仍然不呼叫API只添加本地存儲
export default function SimpleReviewPage() {
  useEffect(() => {
    // 1. 嘗試從localStorage載入
    const savedProgress = loadFromLocalStorage()

    if (savedProgress && isSameDay(savedProgress.timestamp)) {
      setCards(savedProgress.cards)
      setCurrentIndex(savedProgress.currentIndex)
    } else {
      // 2. 無有效存檔則使用靜態數據
      setCards(SIMPLE_CARDS.map(addStateFields))
    }
  }, [])

  // 答題時:更新狀態 + 保存到localStorage
  const handleAnswer = (confidence: number) => {
    const newState = updateLocalState(confidence)
    saveToLocalStorage(newState) // 本地持久化
    // 仍然不呼叫API
  }
}

階段3: API集成 (遠期)

// 明確的API呼叫時機和策略
export default function SimpleReviewPage() {
  const [dataSource, setDataSource] = useState<'static' | 'api'>('static')

  useEffect(() => {
    const loadCards = async () => {
      setLoading(true)

      try {
        // 嘗試API呼叫
        const response = await fetch('/api/flashcards/due?limit=10', {
          headers: {
            'Authorization': `Bearer ${getAuthToken()}`
          }
        })

        if (response.ok) {
          const apiData = await response.json()
          setCards(apiData.data.flashcards.map(addStateFields))
          setDataSource('api')
        } else {
          throw new Error('API failed')
        }
      } catch (error) {
        console.warn('API unavailable, using static data:', error)
        // 降級到靜態數據
        setCards(SIMPLE_CARDS.map(addStateFields))
        setDataSource('static')
      } finally {
        setLoading(false)
      }
    }

    loadCards()
  }, [])

  // 答題時的API呼叫邏輯
  const handleAnswer = async (confidence: number) => {
    // 1. 立即更新前端狀態 (即時響應)
    const newState = updateLocalState(confidence)

    // 2. 如果使用API同步到後端
    if (dataSource === 'api') {
      try {
        await fetch(`/api/flashcards/${currentCard.id}/review`, {
          method: 'POST',
          headers: {
            'Authorization': `Bearer ${getAuthToken()}`,
            'Content-Type': 'application/json'
          },
          body: JSON.stringify({
            confidence: confidence,
            isCorrect: confidence >= 2
          })
        })
      } catch (error) {
        console.warn('Failed to sync to backend:', error)
        // 前端狀態已更新API失敗不影響用戶體驗
      }
    }
  }
}

API呼叫判斷邏輯

// 何時使用API vs 靜態數據
const determineDataSource = () => {
  // 檢查是否有認證Token
  const hasAuth = getAuthToken() !== null

  // 檢查是否在生產環境
  const isProduction = process.env.NODE_ENV === 'production'

  // 檢查是否明確要求使用API
  const forceApi = window.location.search.includes('api=true')

  return (hasAuth && isProduction) || forceApi ? 'api' : 'static'
}

🔄 狀態管理設計

主頁面狀態 (page.tsx)

export default function SimpleReviewPage() {
  // 核心狀態
  const [cards, setCards] = useState<CardState[]>([])
  const [currentIndex, setCurrentIndex] = useState(0)
  const [score, setScore] = useState({ correct: 0, total: 0 })
  const [isComplete, setIsComplete] = useState(false)
  const [mode, setMode] = useState<'flip' | 'choice'>('flip') // 階段2需要

  // 初始化卡片狀態
  useEffect(() => {
    const initialCards: CardState[] = SIMPLE_CARDS.map((card, index) => ({
      ...card,
      skipCount: 0,
      wrongCount: 0,
      successCount: 0,
      isCompleted: false,
      originalOrder: index,
      delayScore: 0,
      lastAttemptAt: new Date()
    }))
    setCards(initialCards)
  }, [])

  // 答題處理
  const handleAnswer = useCallback((confidence: number) => {
    const isCorrect = confidence >= 2 // 一般以上算答對

    if (isCorrect) {
      const result = handleCorrectAnswer(cards, currentIndex)
      setCards(result.updatedCards)
      setCurrentIndex(result.nextIndex)
    } else {
      const result = handleWrongAnswer(cards, currentIndex)
      setCards(result.updatedCards)
      setCurrentIndex(result.nextIndex)
    }

    // 更新分數統計
    setScore(prev => ({
      correct: prev.correct + (isCorrect ? 1 : 0),
      total: prev.total + 1
    }))

    // 檢查是否完成
    checkIfComplete(result.updatedCards)
  }, [cards, currentIndex])

  // 跳過處理
  const handleSkipCard = useCallback(() => {
    const result = handleSkip(cards, currentIndex)
    setCards(result.updatedCards)
    setCurrentIndex(result.nextIndex)

    checkIfComplete(result.updatedCards)
  }, [cards, currentIndex])
}

🎨 UI/UX規格

翻卡動畫CSS (您調教過的)

/* 3D翻卡動畫 */
.flip-card-container {
  perspective: 1000px;
}

.flip-card {
  transform-style: preserve-3d;
  transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}

.flip-card.flipped {
  transform: rotateY(180deg);
}

.flip-card-front,
.flip-card-back {
  backface-visibility: hidden;
  position: absolute;
  width: 100%;
  height: 100%;
}

.flip-card-back {
  transform: rotateY(180deg);
}

響應式設計規格

// 智能高度計算 (您的原設計)
const calculateCardHeight = useCallback(() => {
  if (backRef.current) {
    const backHeight = backRef.current.scrollHeight
    const minHeight = window.innerWidth <= 480 ? 300 :
                     window.innerWidth <= 768 ? 350 : 400
    return Math.max(minHeight, backHeight)
  }
  return 400
}, [])

// 信心度按鈕響應式
const buttonLayout = window.innerWidth <= 640
  ? 'grid-cols-1 gap-2'  // 手機版: 垂直排列
  : 'grid-cols-3 gap-3'  // 桌面版: 水平排列

🔄 本地存儲設計 (階段2+)

進度持久化

// localStorage 結構
interface StoredProgress {
  sessionId: string
  cards: CardState[]
  currentIndex: number
  score: { correct: number; total: number }
  lastSaveTime: string
}

// 儲存進度
const saveProgress = (cards: CardState[], currentIndex: number, score: any) => {
  const progress: StoredProgress = {
    sessionId: `review_${Date.now()}`,
    cards,
    currentIndex,
    score,
    lastSaveTime: new Date().toISOString()
  }

  localStorage.setItem('review-progress', JSON.stringify(progress))
}

// 載入進度
const loadProgress = (): StoredProgress | null => {
  const saved = localStorage.getItem('review-progress')
  if (!saved) return null

  try {
    const progress = JSON.parse(saved)
    // 檢查是否是當日進度 (避免過期數據)
    const saveTime = new Date(progress.lastSaveTime)
    const now = new Date()
    const isToday = saveTime.toDateString() === now.toDateString()

    return isToday ? progress : null
  } catch {
    return null
  }
}

🎯 路由和導航設計

頁面路由

// 路由配置
const reviewRoutes = {
  main: '/review-simple',           // 主複習頁面
  maintenance: '/review',           // 維護頁面 (舊版本隔離)
  backup: '/review-old'            // 備份頁面 (複雜版本)
}

// 導航更新
const navigationItems = [
  { href: '/dashboard', label: '儀表板' },
  { href: '/flashcards', label: '詞卡' },
  { href: '/review-simple', label: '複習' },  // 指向可用版本
  { href: '/generate', label: 'AI 生成' }
]

頁面跳轉邏輯

// 會話完成後的跳轉
const handleComplete = () => {
  setIsComplete(true)
  // 可選: 3秒後自動跳轉
  setTimeout(() => {
    router.push('/dashboard')
  }, 3000)
}

// 中途退出處理
const handleExit = () => {
  if (window.confirm('確定要退出複習嗎?進度將不會保存。')) {
    router.push('/flashcards')
  }
}

📊 性能優化規格

React性能優化

// 使用 memo 避免不必要重渲染
export const SimpleFlipCard = memo(SimpleFlipCardComponent)
export const SimpleProgress = memo(SimpleProgressComponent)

// useCallback 穩定化函數引用
const handleAnswer = useCallback((confidence: number) => {
  // ... 邏輯
}, [cards, currentIndex])

// useMemo 緩存計算結果
const sortedCards = useMemo(() =>
  sortCardsByPriority(cards),
  [cards]
)

const currentCard = useMemo(() =>
  cards[currentIndex],
  [cards, currentIndex]
)

載入性能目標

// 性能指標
const PERFORMANCE_TARGETS = {
  INITIAL_LOAD: 1500,    // 初始載入 < 1.5秒
  CARD_FLIP: 300,        // 翻卡動畫 < 300ms
  SORT_OPERATION: 100,   // 排序計算 < 100ms
  STATE_UPDATE: 50,      // 狀態更新 < 50ms
  NAVIGATION: 200        // 頁面跳轉 < 200ms
}

// 性能監控
const measurePerformance = (operation: string, fn: Function) => {
  const start = performance.now()
  const result = fn()
  const end = performance.now()

  console.log(`${operation}: ${end - start}ms`)
  return result
}

🧪 測試架構

測試文件結構

__tests__/
├── delay-counting-system.test.ts    # 延遲計數邏輯測試
├── card-sorting.test.ts             # 排序算法測試
├── confidence-mapping.test.ts       # 信心度映射測試
└── components/
    ├── SimpleFlipCard.test.tsx      # 翻卡組件測試
    ├── SimpleProgress.test.tsx      # 進度組件測試
    └── integration.test.tsx         # 完整流程集成測試

Mock數據設計

// 測試用的 Mock 數據
export const MOCK_CARDS: CardState[] = [
  {
    id: 'test-1',
    word: 'evidence',
    definition: 'facts or information indicating truth',
    skipCount: 0,
    wrongCount: 0,
    successCount: 0,
    isCompleted: false,
    originalOrder: 0,
    delayScore: 0,
    // ... 其他 API 欄位
  },
  {
    id: 'test-2',
    word: 'priority',
    definition: 'the fact of being more important',
    skipCount: 2,
    wrongCount: 1,
    successCount: 0,
    isCompleted: false,
    originalOrder: 1,
    delayScore: 3,
    // ... 其他 API 欄位
  }
]

🔧 開發工具配置

TypeScript 配置

// 嚴格的類型檢查
interface 必須完整定義
Props 必須有明確類型
回調函數必須有返回值類型
狀態更新必須使用正確的類型

// 避免 any 類型
禁止: any, object, Function
建議: 具體的接口定義

ESLint 規則

// 代碼品質規則
'react-hooks/exhaustive-deps': 'error'  // 確保 useEffect 依賴正確
'react/no-array-index-key': 'warn'     // 避免使用 index 作為 key
'@typescript-eslint/no-unused-vars': 'error' // 禁止未使用變數

// 複雜度控制
'max-lines': ['error', 200]            // 組件最多200行
'max-params': ['error', 5]             // 函數最多5個參數
'complexity': ['error', 10]            // 圈複雜度最多10

📱 使用者體驗規格

載入狀態處理

// 載入狀態
const [isLoading, setIsLoading] = useState(true)

// 載入動畫
const LoadingSpinner = () => (
  <div className="flex items-center justify-center h-64">
    <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
    <span className="ml-3 text-gray-600">準備詞卡中...</span>
  </div>
)

錯誤處理

// 錯誤狀態
const [error, setError] = useState<string | null>(null)

// 錯誤邊界
const ErrorBoundary = ({ error, onRetry }) => (
  <div className="bg-red-50 border border-red-200 rounded-lg p-6">
    <h3 className="text-lg font-semibold text-red-700 mb-2">發生錯誤</h3>
    <p className="text-red-600 mb-4">{error}</p>
    <button
      onClick={onRetry}
      className="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700"
    >
      重新嘗試
    </button>
  </div>
)

無障礙設計

// 鍵盤操作支援
const handleKeyDown = (e: KeyboardEvent) => {
  switch (e.key) {
    case 'ArrowLeft': handleSkip(); break
    case 'ArrowRight': handleAnswer(2); break  // 一般
    case 'ArrowUp': handleAnswer(3); break     // 熟悉
    case 'ArrowDown': handleAnswer(1); break   // 模糊
    case ' ': handleFlip(); break              // 空格翻卡
  }
}

// ARIA 標籤
<button
  aria-label={`信心度選擇: ${label}`}
  role="button"
  tabIndex={0}
>
  {label}
</button>

📋 開發檢查清單

組件開發完成標準

  • TypeScript 無錯誤和警告
  • 所有 props 都有預設值或必填檢查
  • 使用 memo/useCallback 優化性能
  • 響應式設計在手機和桌面都正常
  • 無障礙功能完整 (鍵盤、ARIA)
  • 錯誤狀態有適當處理

功能測試標準

  • 所有延遲計數測試通過
  • 排序邏輯測試通過
  • 信心度映射測試通過
  • 完整流程集成測試通過
  • 邊界條件測試通過

前端規格維護: 開發團隊 更新觸發: 產品需求變更或技術實作調整 目標: 確保前端實作準確性和一致性