feat: 實現線性雙測驗流程系統
## 主要功能 - 實現線性複習流程:A翻卡 → A選擇 → B翻卡 → B選擇... - 測驗項目級別的狀態管理和進度追蹤 - 自動測驗類型切換,無需用戶選擇 ## 核心改進 - 新增 TestItem 數據結構支援線性流程 - 重構 useReviewSession Hook 管理測驗項目 - 修正延遲計數系統優先級排序邏輯 - 統一兩種測驗的跳過按鈕位置 ## 評分標準修正 - 翻卡記憶:一般(1分)以上算答對 - 詞彙選擇:正確選擇算答對 - 答錯的測驗項目不標記完成,會重新出現 ## 用戶體驗改善 - 進入頁面自動開始線性測驗 - 清楚的測驗類型和進度指示 - 測驗項目序列可視化 - 延遲計數系統視覺反饋 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
04def4bb85
commit
51e5870390
|
|
@ -3,19 +3,23 @@
|
||||||
import { Navigation } from '@/components/shared/Navigation'
|
import { Navigation } from '@/components/shared/Navigation'
|
||||||
import './globals.css'
|
import './globals.css'
|
||||||
import { SimpleFlipCard } from '@/components/review/simple/SimpleFlipCard'
|
import { SimpleFlipCard } from '@/components/review/simple/SimpleFlipCard'
|
||||||
|
import { VocabChoiceTest } from '@/components/review/simple/VocabChoiceTest'
|
||||||
import { SimpleProgress } from '@/components/review/simple/SimpleProgress'
|
import { SimpleProgress } from '@/components/review/simple/SimpleProgress'
|
||||||
import { SimpleResults } from '@/components/review/simple/SimpleResults'
|
import { SimpleResults } from '@/components/review/simple/SimpleResults'
|
||||||
import { SIMPLE_CARDS, CardState } from '@/lib/data/reviewSimpleData'
|
import { SIMPLE_CARDS } from '@/lib/data/reviewSimpleData'
|
||||||
import { useReviewSession } from '@/hooks/review/useReviewSession'
|
import { useReviewSession } from '@/hooks/review/useReviewSession'
|
||||||
|
|
||||||
export default function SimpleReviewPage() {
|
export default function SimpleReviewPage() {
|
||||||
// 使用自定義 Hook 管理複習狀態
|
// 使用重構後的 Hook 管理線性複習狀態
|
||||||
const {
|
const {
|
||||||
cards,
|
testItems,
|
||||||
score,
|
score,
|
||||||
isComplete,
|
isComplete,
|
||||||
|
currentTestItem,
|
||||||
currentCard,
|
currentCard,
|
||||||
sortedCards,
|
vocabOptions,
|
||||||
|
totalTestItems,
|
||||||
|
completedTestItems,
|
||||||
handleAnswer,
|
handleAnswer,
|
||||||
handleSkip,
|
handleSkip,
|
||||||
handleRestart
|
handleRestart
|
||||||
|
|
@ -27,42 +31,86 @@ export default function SimpleReviewPage() {
|
||||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
|
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
|
||||||
<Navigation />
|
<Navigation />
|
||||||
<div className="py-8">
|
<div className="py-8">
|
||||||
<SimpleResults
|
<div className="max-w-4xl mx-auto px-4">
|
||||||
score={score}
|
<SimpleResults
|
||||||
totalCards={SIMPLE_CARDS.length}
|
score={score}
|
||||||
onRestart={handleRestart}
|
totalCards={SIMPLE_CARDS.length}
|
||||||
/>
|
onRestart={handleRestart}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 線性測驗完成統計 */}
|
||||||
|
<div className="mt-6 bg-white rounded-lg shadow p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">測驗統計</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-blue-600">{completedTestItems}</div>
|
||||||
|
<div className="text-sm text-gray-600">完成測驗項目</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-green-600">{SIMPLE_CARDS.length}</div>
|
||||||
|
<div className="text-sm text-gray-600">練習詞卡數</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-purple-600">
|
||||||
|
{Math.round((score.correct / score.total) * 100)}%
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600">正確率</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 主要複習頁面
|
// 主要線性測驗頁面
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
|
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
|
||||||
<Navigation />
|
<Navigation />
|
||||||
|
|
||||||
<div className="py-8">
|
<div className="py-8">
|
||||||
<div className="max-w-4xl mx-auto px-4">
|
<div className="max-w-4xl mx-auto px-4">
|
||||||
{/* 進度顯示 */}
|
{/* 使用修改後的 SimpleProgress 組件 */}
|
||||||
<SimpleProgress
|
<SimpleProgress
|
||||||
current={cards.filter((card: CardState) => card.isCompleted).length + 1}
|
currentTestItem={currentTestItem}
|
||||||
total={cards.length}
|
totalTestItems={totalTestItems}
|
||||||
|
completedTestItems={completedTestItems}
|
||||||
score={score}
|
score={score}
|
||||||
cards={cards}
|
testItems={testItems}
|
||||||
sortedCards={sortedCards}
|
|
||||||
currentCard={currentCard}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 翻卡組件 */}
|
{/* 根據當前測驗項目類型渲染對應組件 */}
|
||||||
{currentCard && (
|
{currentTestItem && currentCard && (
|
||||||
<SimpleFlipCard
|
<>
|
||||||
card={currentCard}
|
{currentTestItem.testType === 'flip-card' && (
|
||||||
onAnswer={handleAnswer}
|
<SimpleFlipCard
|
||||||
onSkip={handleSkip}
|
card={currentCard}
|
||||||
/>
|
onAnswer={handleAnswer}
|
||||||
|
onSkip={handleSkip}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentTestItem.testType === 'vocab-choice' && (
|
||||||
|
<VocabChoiceTest
|
||||||
|
card={currentCard}
|
||||||
|
options={vocabOptions}
|
||||||
|
onAnswer={handleAnswer}
|
||||||
|
onSkip={handleSkip}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 控制按鈕 */}
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<button
|
||||||
|
onClick={handleRestart}
|
||||||
|
className="px-4 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600"
|
||||||
|
>
|
||||||
|
重新開始
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { Navigation } from '@/components/shared/Navigation'
|
|
||||||
import { VocabChoiceTest } from '@/components/review/simple/VocabChoiceTest'
|
|
||||||
import { SIMPLE_CARDS, CardState } from '@/lib/data/reviewSimpleData'
|
|
||||||
|
|
||||||
export default function TestVocabChoicePage() {
|
|
||||||
const [currentCardIndex, setCurrentCardIndex] = useState(0)
|
|
||||||
const [score, setScore] = useState({ correct: 0, total: 0 })
|
|
||||||
|
|
||||||
const currentCard = SIMPLE_CARDS[currentCardIndex]
|
|
||||||
|
|
||||||
// 生成測驗選項 (包含正確答案和3個干擾項)
|
|
||||||
const generateOptions = (correctWord: string) => {
|
|
||||||
const allWords = SIMPLE_CARDS.map(card => card.word).filter(word => word !== correctWord)
|
|
||||||
const shuffledWords = allWords.sort(() => Math.random() - 0.5)
|
|
||||||
const distractors = shuffledWords.slice(0, 3)
|
|
||||||
|
|
||||||
const options = [correctWord, ...distractors]
|
|
||||||
return options.sort(() => Math.random() - 0.5) // 隨機排列選項
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAnswer = (confidence: number) => {
|
|
||||||
const isCorrect = confidence >= 2
|
|
||||||
setScore(prev => ({
|
|
||||||
correct: prev.correct + (isCorrect ? 1 : 0),
|
|
||||||
total: prev.total + 1
|
|
||||||
}))
|
|
||||||
|
|
||||||
// 移動到下一張卡片
|
|
||||||
setTimeout(() => {
|
|
||||||
if (currentCardIndex < SIMPLE_CARDS.length - 1) {
|
|
||||||
setCurrentCardIndex(prev => prev + 1)
|
|
||||||
} else {
|
|
||||||
alert(`測試完成!正確率: ${score.correct + (isCorrect ? 1 : 0)}/${score.total + 1}`)
|
|
||||||
}
|
|
||||||
}, 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSkip = () => {
|
|
||||||
setScore(prev => ({ ...prev, total: prev.total + 1 }))
|
|
||||||
|
|
||||||
if (currentCardIndex < SIMPLE_CARDS.length - 1) {
|
|
||||||
setCurrentCardIndex(prev => prev + 1)
|
|
||||||
} else {
|
|
||||||
alert(`測試完成!正確率: ${score.correct}/${score.total + 1}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRestart = () => {
|
|
||||||
setCurrentCardIndex(0)
|
|
||||||
setScore({ correct: 0, total: 0 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentCard) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
|
|
||||||
<Navigation />
|
|
||||||
<div className="py-8">
|
|
||||||
<div className="max-w-4xl mx-auto px-4 text-center">
|
|
||||||
<h1 className="text-2xl font-bold mb-4">測試完成!</h1>
|
|
||||||
<button
|
|
||||||
onClick={handleRestart}
|
|
||||||
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
重新開始
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
|
|
||||||
<Navigation />
|
|
||||||
|
|
||||||
<div className="py-8">
|
|
||||||
<div className="max-w-4xl mx-auto px-4">
|
|
||||||
{/* 進度顯示 */}
|
|
||||||
<div className="mb-6 bg-white rounded-lg shadow p-4">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<h1 className="text-xl font-bold">詞彙選擇測驗</h1>
|
|
||||||
<div className="text-sm text-gray-600">
|
|
||||||
第 {currentCardIndex + 1} / {SIMPLE_CARDS.length} 題
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2">
|
|
||||||
<div className="text-sm text-gray-600 mb-1">
|
|
||||||
得分: {score.correct} / {score.total}
|
|
||||||
{score.total > 0 && ` (${Math.round(score.correct / score.total * 100)}%)`}
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
|
||||||
<div
|
|
||||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
|
||||||
style={{ width: `${((currentCardIndex) / SIMPLE_CARDS.length) * 100}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 測驗組件 */}
|
|
||||||
<VocabChoiceTest
|
|
||||||
card={currentCard}
|
|
||||||
options={generateOptions(currentCard.word)}
|
|
||||||
onAnswer={handleAnswer}
|
|
||||||
onSkip={handleSkip}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 控制按鈕 */}
|
|
||||||
<div className="mt-6 text-center">
|
|
||||||
<button
|
|
||||||
onClick={handleRestart}
|
|
||||||
className="px-4 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 mr-4"
|
|
||||||
>
|
|
||||||
重新開始
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +1,43 @@
|
||||||
import { CardState } from '@/lib/data/reviewSimpleData'
|
import { TestItem } from '@/lib/data/reviewSimpleData'
|
||||||
|
|
||||||
interface SimpleProgressProps {
|
interface SimpleProgressProps {
|
||||||
current: number
|
currentTestItem?: TestItem
|
||||||
total: number
|
totalTestItems: number
|
||||||
|
completedTestItems: number
|
||||||
score: { correct: number; total: number }
|
score: { correct: number; total: number }
|
||||||
cards?: CardState[] // 可選:用於顯示延遲統計
|
testItems?: TestItem[] // 用於顯示測驗項目統計
|
||||||
sortedCards?: CardState[] // 智能排序後的卡片
|
|
||||||
currentCard?: CardState // 當前正在練習的卡片
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SimpleProgress({ current, total, score, cards, sortedCards, currentCard }: SimpleProgressProps) {
|
export function SimpleProgress({ currentTestItem, totalTestItems, completedTestItems, score, testItems }: SimpleProgressProps) {
|
||||||
const progress = (current - 1) / total * 100
|
const progress = (completedTestItems / totalTestItems) * 100
|
||||||
const accuracy = score.total > 0 ? Math.round((score.correct / score.total) * 100) : 0
|
const accuracy = score.total > 0 ? Math.round((score.correct / score.total) * 100) : 0
|
||||||
|
|
||||||
// 延遲統計計算
|
// 測驗項目延遲統計計算
|
||||||
const delayStats = cards ? {
|
const delayStats = testItems ? {
|
||||||
totalSkips: cards.reduce((sum, card) => sum + card.skipCount, 0),
|
totalSkips: testItems.reduce((sum, item) => sum + item.skipCount, 0),
|
||||||
totalWrongs: cards.reduce((sum, card) => sum + card.wrongCount, 0),
|
totalWrongs: testItems.reduce((sum, item) => sum + item.wrongCount, 0),
|
||||||
delayedCards: cards.filter(card => card.skipCount + card.wrongCount > 0).length
|
delayedItems: testItems.filter(item => item.skipCount + item.wrongCount > 0).length
|
||||||
} : null
|
} : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex justify-between items-center mb-3">
|
<div className="flex justify-between items-center mb-3">
|
||||||
<span className="text-sm font-medium text-gray-600">學習進度</span>
|
<div>
|
||||||
<div className="flex items-center gap-4 text-sm">
|
<span className="text-sm font-medium text-gray-600">線性複習進度</span>
|
||||||
|
{currentTestItem && (
|
||||||
|
<div className="flex items-center mt-1">
|
||||||
|
<span className="text-lg mr-2">
|
||||||
|
{currentTestItem.testType === 'flip-card' ? '🔄' : '🎯'}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{currentTestItem.testType === 'flip-card' ? '翻卡記憶' : '詞彙選擇'} • {currentTestItem.cardData.word}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-right">
|
||||||
<span className="text-gray-600">
|
<span className="text-gray-600">
|
||||||
{current}/{total}
|
{completedTestItems}/{totalTestItems} 項目
|
||||||
</span>
|
</span>
|
||||||
{score.total > 0 && (
|
{score.total > 0 && (
|
||||||
<span className="text-green-600 font-medium">
|
<span className="text-green-600 font-medium">
|
||||||
|
|
@ -69,53 +80,56 @@ export function SimpleProgress({ current, total, score, cards, sortedCards, curr
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="w-2 h-2 bg-blue-500 rounded-full"></span>
|
<span className="w-2 h-2 bg-blue-500 rounded-full"></span>
|
||||||
<span className="text-blue-700">跳過卡片 {delayStats.delayedCards}</span>
|
<span className="text-blue-700">跳過卡片 {delayStats.delayedItems}</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 詞彙順序可視化 - 便於驗證延遲計數系統 */}
|
{/* 測驗項目順序可視化 */}
|
||||||
{sortedCards && currentCard && (
|
{testItems && currentTestItem && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<div className="bg-white/50 rounded-lg p-3">
|
<div className="bg-white/50 rounded-lg p-3">
|
||||||
<p className="text-sm text-gray-600 mb-2">詞彙順序 (按延遲分數排序):</p>
|
<p className="text-sm text-gray-600 mb-2">測驗項目序列 (線性流程):</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{sortedCards.map((card, index) => {
|
{testItems.slice(0, 12).map((item) => {
|
||||||
const isCompleted = card.isCompleted
|
const isCompleted = item.isCompleted
|
||||||
const delayScore = card.skipCount + card.wrongCount
|
const isCurrent = item.id === currentTestItem?.id
|
||||||
|
const delayScore = item.skipCount + item.wrongCount
|
||||||
|
|
||||||
// 狀態顏色
|
// 狀態顏色
|
||||||
let cardStyle = ''
|
let itemStyle = ''
|
||||||
let statusText = ''
|
let statusText = ''
|
||||||
|
|
||||||
if (isCompleted) {
|
if (isCompleted) {
|
||||||
cardStyle = 'bg-green-100 text-green-700 border-green-300'
|
itemStyle = 'bg-green-100 text-green-700 border-green-300'
|
||||||
statusText = '✓'
|
statusText = '✓'
|
||||||
} else if (delayScore > 0) {
|
} else if (delayScore > 0) {
|
||||||
if (card.skipCount > 0 && card.wrongCount > 0) {
|
if (item.skipCount > 0 && item.wrongCount > 0) {
|
||||||
cardStyle = 'bg-red-100 text-red-700 border-red-300'
|
itemStyle = 'bg-red-100 text-red-700 border-red-300'
|
||||||
statusText = `跳${card.skipCount}錯${card.wrongCount}`
|
statusText = `${item.skipCount}+${item.wrongCount}`
|
||||||
} else if (card.skipCount > 0) {
|
} else if (item.skipCount > 0) {
|
||||||
cardStyle = 'bg-yellow-100 text-yellow-700 border-yellow-300'
|
itemStyle = 'bg-yellow-100 text-yellow-700 border-yellow-300'
|
||||||
statusText = `跳${card.skipCount}`
|
statusText = `跳${item.skipCount}`
|
||||||
} else {
|
} else {
|
||||||
cardStyle = 'bg-orange-100 text-orange-700 border-orange-300'
|
itemStyle = 'bg-orange-100 text-orange-700 border-orange-300'
|
||||||
statusText = `錯${card.wrongCount}`
|
statusText = `錯${item.wrongCount}`
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
cardStyle = 'bg-gray-100 text-gray-600 border-gray-300'
|
itemStyle = 'bg-gray-100 text-gray-600 border-gray-300'
|
||||||
statusText = ''
|
statusText = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={card.id}
|
key={item.id}
|
||||||
className={`px-3 py-2 rounded-lg border text-sm font-medium ${cardStyle}`}
|
className={`px-3 py-2 rounded-lg border text-sm font-medium ${itemStyle}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span>{index + 1}.</span>
|
<span className="text-xs">
|
||||||
<span className="font-semibold">{card.word}</span>
|
{item.testType === 'flip-card' ? '🔄' : '🎯'}
|
||||||
|
</span>
|
||||||
|
<span>{item.cardData.word}</span>
|
||||||
{statusText && (
|
{statusText && (
|
||||||
<span className="text-xs">({statusText})</span>
|
<span className="text-xs">({statusText})</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -123,9 +137,14 @@ export function SimpleProgress({ current, total, score, cards, sortedCards, curr
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
{testItems.length > 12 && (
|
||||||
|
<div className="px-3 py-2 text-sm text-gray-500">
|
||||||
|
...還有 {testItems.length - 12} 個項目
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 mt-2 text-right">
|
<p className="text-xs text-gray-500 mt-2 text-right">
|
||||||
🟢完成、🟡跳過、🟠答錯、🔴跳過+答錯、⚪未開始
|
🟢完成、🟡跳過、🟠答錯、🔴多次&答錯、⚪待進行
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
import { CardState } from '../data'
|
|
||||||
|
|
||||||
interface SimpleResultsProps {
|
interface SimpleResultsProps {
|
||||||
score: { correct: number; total: number }
|
score: { correct: number; total: number }
|
||||||
totalCards: number
|
totalCards: number
|
||||||
cards?: CardState[]
|
|
||||||
onRestart: () => void
|
onRestart: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,97 +41,99 @@ export function VocabChoiceTest({ card, options, onAnswer, onSkip }: VocabChoice
|
||||||
const isCorrect = selectedAnswer === card.word
|
const isCorrect = selectedAnswer === card.word
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-xl shadow-lg p-8">
|
<div>
|
||||||
<SimpleTestHeader
|
<div className="bg-white rounded-xl shadow-lg p-8">
|
||||||
title="詞彙選擇"
|
<SimpleTestHeader
|
||||||
cefr={card.cefr}
|
title="詞彙選擇"
|
||||||
/>
|
cefr={card.cefr}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 問題區域 */}
|
{/* 問題區域 */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="bg-gray-50 rounded-lg p-6 mb-4">
|
<div className="bg-gray-50 rounded-lg p-6 mb-4">
|
||||||
<h3 className="font-semibold text-gray-900 mb-3 text-left">定義</h3>
|
<h3 className="font-semibold text-gray-900 mb-3 text-left">定義</h3>
|
||||||
<p className="text-gray-700 text-left text-lg leading-relaxed">{card.definition}</p>
|
<p className="text-gray-700 text-left text-lg leading-relaxed">{card.definition}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg text-gray-700 text-left">
|
||||||
|
請選擇符合上述定義的英文詞彙:
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg text-gray-700 text-left">
|
|
||||||
請選擇符合上述定義的英文詞彙:
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 選項區域 */}
|
{/* 選項區域 */}
|
||||||
<div className="mb-6">
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
{options.map((option, index) => {
|
|
||||||
const isSelected = selectedAnswer === option
|
|
||||||
const isCorrectOption = option === card.word
|
|
||||||
|
|
||||||
let buttonClass = 'p-4 rounded-lg border-2 text-center font-medium transition-all duration-200 cursor-pointer active:scale-95'
|
|
||||||
|
|
||||||
if (showResult) {
|
|
||||||
if (isSelected && isCorrectOption) {
|
|
||||||
// 選中且正確
|
|
||||||
buttonClass += ' bg-green-100 text-green-700 border-green-200 ring-2 ring-green-400'
|
|
||||||
} else if (isSelected && !isCorrectOption) {
|
|
||||||
// 選中但錯誤
|
|
||||||
buttonClass += ' bg-red-100 text-red-700 border-red-200 ring-2 ring-red-400'
|
|
||||||
} else if (!isSelected && isCorrectOption) {
|
|
||||||
// 未選中但是正確答案
|
|
||||||
buttonClass += ' bg-green-50 text-green-600 border-green-200'
|
|
||||||
} else {
|
|
||||||
// 未選中且非正確答案
|
|
||||||
buttonClass += ' bg-gray-50 text-gray-500 border-gray-200'
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 未答題狀態
|
|
||||||
buttonClass += ' bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100'
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={index}
|
|
||||||
onClick={() => handleAnswerSelect(option)}
|
|
||||||
disabled={showResult}
|
|
||||||
className={buttonClass}
|
|
||||||
>
|
|
||||||
{option}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 結果顯示區域 */}
|
|
||||||
{showResult && (
|
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className={`p-4 rounded-lg ${isCorrect ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}`}>
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="flex items-center mb-3">
|
{options.map((option, index) => {
|
||||||
<span className={`text-2xl mr-3 ${isCorrect ? 'text-green-600' : 'text-red-600'}`}>
|
const isSelected = selectedAnswer === option
|
||||||
{isCorrect ? '✅' : '❌'}
|
const isCorrectOption = option === card.word
|
||||||
</span>
|
|
||||||
<h3 className={`text-lg font-semibold ${isCorrect ? 'text-green-800' : 'text-red-800'}`}>
|
|
||||||
{isCorrect ? '答對了!' : '答錯了'}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2 text-left">
|
let buttonClass = 'p-4 rounded-lg border-2 text-center font-medium transition-all duration-200 cursor-pointer active:scale-95'
|
||||||
<p className="text-gray-700">
|
|
||||||
<strong>正確答案:</strong> {card.word}
|
if (showResult) {
|
||||||
</p>
|
if (isSelected && isCorrectOption) {
|
||||||
<p className="text-gray-700">
|
// 選中且正確
|
||||||
<strong>發音:</strong> {card.pronunciation}
|
buttonClass += ' bg-green-100 text-green-700 border-green-200 ring-2 ring-green-400'
|
||||||
</p>
|
} else if (isSelected && !isCorrectOption) {
|
||||||
<p className="text-gray-700">
|
// 選中但錯誤
|
||||||
<strong>例句:</strong> "{card.example}"
|
buttonClass += ' bg-red-100 text-red-700 border-red-200 ring-2 ring-red-400'
|
||||||
</p>
|
} else if (!isSelected && isCorrectOption) {
|
||||||
<p className="text-gray-600 text-sm">
|
// 未選中但是正確答案
|
||||||
{card.exampleTranslation}
|
buttonClass += ' bg-green-50 text-green-600 border-green-200'
|
||||||
</p>
|
} else {
|
||||||
</div>
|
// 未選中且非正確答案
|
||||||
|
buttonClass += ' bg-gray-50 text-gray-500 border-gray-200'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 未答題狀態
|
||||||
|
buttonClass += ' bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={() => handleAnswerSelect(option)}
|
||||||
|
disabled={showResult}
|
||||||
|
className={buttonClass}
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 跳過按鈕 */}
|
{/* 結果顯示區域 */}
|
||||||
|
{showResult && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className={`p-4 rounded-lg ${isCorrect ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}`}>
|
||||||
|
<div className="flex items-center mb-3">
|
||||||
|
<span className={`text-2xl mr-3 ${isCorrect ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
|
{isCorrect ? '✅' : '❌'}
|
||||||
|
</span>
|
||||||
|
<h3 className={`text-lg font-semibold ${isCorrect ? 'text-green-800' : 'text-red-800'}`}>
|
||||||
|
{isCorrect ? '答對了!' : '答錯了'}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-left">
|
||||||
|
<p className="text-gray-700">
|
||||||
|
<strong>正確答案:</strong> {card.word}
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-700">
|
||||||
|
<strong>發音:</strong> {card.pronunciation}
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-700">
|
||||||
|
<strong>例句:</strong> "{card.example}"
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-600 text-sm">
|
||||||
|
{card.exampleTranslation}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 跳過按鈕 - 移到卡片外面 */}
|
||||||
{!showResult && (
|
{!showResult && (
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,35 @@
|
||||||
import { useReducer, useEffect, useMemo } from 'react'
|
import { useReducer, useEffect, useMemo } from 'react'
|
||||||
import { SIMPLE_CARDS, CardState, sortCardsByPriority } from '@/lib/data/reviewSimpleData'
|
import {
|
||||||
|
INITIAL_TEST_ITEMS,
|
||||||
|
TestItem,
|
||||||
|
CardState,
|
||||||
|
sortTestItemsByPriority,
|
||||||
|
generateVocabOptions,
|
||||||
|
SIMPLE_CARDS
|
||||||
|
} from '@/lib/data/reviewSimpleData'
|
||||||
|
|
||||||
interface ReviewState {
|
interface ReviewState {
|
||||||
cards: CardState[]
|
testItems: TestItem[]
|
||||||
score: { correct: number; total: number }
|
score: { correct: number; total: number }
|
||||||
isComplete: boolean
|
isComplete: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReviewAction =
|
type ReviewAction =
|
||||||
| { type: 'LOAD_PROGRESS'; payload: ReviewState }
|
| { type: 'LOAD_PROGRESS'; payload: ReviewState }
|
||||||
| { type: 'ANSWER_CARD'; payload: { cardId: string; confidence: number } }
|
| { type: 'ANSWER_TEST_ITEM'; payload: { testItemId: string; confidence: number } }
|
||||||
| { type: 'SKIP_CARD'; payload: { cardId: string } }
|
| { type: 'SKIP_TEST_ITEM'; payload: { testItemId: string } }
|
||||||
| { type: 'RESTART' }
|
| { type: 'RESTART' }
|
||||||
|
|
||||||
// 內部狀態更新函數
|
// 內部測驗項目更新函數
|
||||||
const updateCardState = (
|
const updateTestItem = (
|
||||||
cards: CardState[],
|
testItems: TestItem[],
|
||||||
cardIndex: number,
|
testItemId: string,
|
||||||
updates: Partial<CardState>
|
updates: Partial<TestItem>
|
||||||
): CardState[] => {
|
): TestItem[] => {
|
||||||
return cards.map((card, index) =>
|
return testItems.map((item) =>
|
||||||
index === cardIndex
|
item.id === testItemId
|
||||||
? { ...card, ...updates }
|
? { ...item, ...updates }
|
||||||
: card
|
: item
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -31,60 +38,50 @@ const reviewReducer = (state: ReviewState, action: ReviewAction): ReviewState =>
|
||||||
case 'LOAD_PROGRESS':
|
case 'LOAD_PROGRESS':
|
||||||
return action.payload
|
return action.payload
|
||||||
|
|
||||||
case 'ANSWER_CARD': {
|
case 'ANSWER_TEST_ITEM': {
|
||||||
const { cardId, confidence } = action.payload
|
const { testItemId, confidence } = action.payload
|
||||||
const isCorrect = confidence >= 2
|
const isCorrect = confidence >= 1 // 修正:一般(1分)以上都算答對
|
||||||
|
|
||||||
// 使用 Map 優化查找性能
|
const testItem = state.testItems.find(item => item.id === testItemId)
|
||||||
const cardMap = new Map(state.cards.map((card, index) => [card.id, { card, index }]))
|
if (!testItem) return state
|
||||||
const cardData = cardMap.get(cardId)
|
|
||||||
|
|
||||||
if (!cardData) return state
|
// 修正:只有答對才標記為完成,答錯只增加錯誤次數
|
||||||
|
const updatedTestItems = updateTestItem(state.testItems, testItemId,
|
||||||
const { index: cardIndex } = cardData
|
isCorrect
|
||||||
const currentCard = state.cards[cardIndex]
|
? { isCompleted: true } // 答對:標記完成
|
||||||
|
: { wrongCount: testItem.wrongCount + 1 } // 答錯:只增加錯誤次數,不完成
|
||||||
const updatedCards = updateCardState(state.cards, cardIndex, {
|
)
|
||||||
isCompleted: isCorrect,
|
|
||||||
wrongCount: isCorrect ? currentCard.wrongCount : currentCard.wrongCount + 1
|
|
||||||
})
|
|
||||||
|
|
||||||
const newScore = {
|
const newScore = {
|
||||||
correct: state.score.correct + (isCorrect ? 1 : 0),
|
correct: state.score.correct + (isCorrect ? 1 : 0),
|
||||||
total: state.score.total + 1
|
total: state.score.total + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
const remainingCards = updatedCards.filter(card => !card.isCompleted)
|
const remainingTestItems = updatedTestItems.filter(item => !item.isCompleted)
|
||||||
const isComplete = remainingCards.length === 0
|
const isComplete = remainingTestItems.length === 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cards: updatedCards,
|
testItems: updatedTestItems,
|
||||||
score: newScore,
|
score: newScore,
|
||||||
isComplete
|
isComplete
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'SKIP_CARD': {
|
case 'SKIP_TEST_ITEM': {
|
||||||
const { cardId } = action.payload
|
const { testItemId } = action.payload
|
||||||
|
|
||||||
// 使用 Map 優化查找性能
|
const testItem = state.testItems.find(item => item.id === testItemId)
|
||||||
const cardMap = new Map(state.cards.map((card, index) => [card.id, { card, index }]))
|
if (!testItem) return state
|
||||||
const cardData = cardMap.get(cardId)
|
|
||||||
|
|
||||||
if (!cardData) return state
|
const updatedTestItems = updateTestItem(state.testItems, testItemId, {
|
||||||
|
skipCount: testItem.skipCount + 1
|
||||||
const { index: cardIndex } = cardData
|
|
||||||
const currentCard = state.cards[cardIndex]
|
|
||||||
|
|
||||||
const updatedCards = updateCardState(state.cards, cardIndex, {
|
|
||||||
skipCount: currentCard.skipCount + 1
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const remainingCards = updatedCards.filter(card => !card.isCompleted)
|
const remainingTestItems = updatedTestItems.filter(item => !item.isCompleted)
|
||||||
const isComplete = remainingCards.length === 0
|
const isComplete = remainingTestItems.length === 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cards: updatedCards,
|
testItems: updatedTestItems,
|
||||||
score: state.score,
|
score: state.score,
|
||||||
isComplete
|
isComplete
|
||||||
}
|
}
|
||||||
|
|
@ -92,7 +89,7 @@ const reviewReducer = (state: ReviewState, action: ReviewAction): ReviewState =>
|
||||||
|
|
||||||
case 'RESTART':
|
case 'RESTART':
|
||||||
return {
|
return {
|
||||||
cards: SIMPLE_CARDS,
|
testItems: INITIAL_TEST_ITEMS,
|
||||||
score: { correct: 0, total: 0 },
|
score: { correct: 0, total: 0 },
|
||||||
isComplete: false
|
isComplete: false
|
||||||
}
|
}
|
||||||
|
|
@ -105,25 +102,26 @@ const reviewReducer = (state: ReviewState, action: ReviewAction): ReviewState =>
|
||||||
export function useReviewSession() {
|
export function useReviewSession() {
|
||||||
// 使用 useReducer 統一狀態管理
|
// 使用 useReducer 統一狀態管理
|
||||||
const [state, dispatch] = useReducer(reviewReducer, {
|
const [state, dispatch] = useReducer(reviewReducer, {
|
||||||
cards: SIMPLE_CARDS,
|
testItems: INITIAL_TEST_ITEMS,
|
||||||
score: { correct: 0, total: 0 },
|
score: { correct: 0, total: 0 },
|
||||||
isComplete: false
|
isComplete: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const { cards, score, isComplete } = state
|
const { testItems, score, isComplete } = state
|
||||||
|
|
||||||
// 智能排序獲取當前卡片 - 使用 useMemo 優化性能
|
// 智能排序獲取當前測驗項目 - 使用 useMemo 優化性能
|
||||||
const sortedCards = useMemo(() => sortCardsByPriority(cards), [cards])
|
const sortedTestItems = useMemo(() => sortTestItemsByPriority(testItems), [testItems])
|
||||||
const incompleteCards = useMemo(() =>
|
const incompleteTestItems = useMemo(() =>
|
||||||
sortedCards.filter((card: CardState) => !card.isCompleted),
|
sortedTestItems.filter((item: TestItem) => !item.isCompleted),
|
||||||
[sortedCards]
|
[sortedTestItems]
|
||||||
)
|
)
|
||||||
const currentCard = incompleteCards[0] // 總是選擇優先級最高的未完成卡片
|
const currentTestItem = incompleteTestItems[0] // 總是選擇優先級最高的未完成測驗項目
|
||||||
|
const currentCard = currentTestItem?.cardData // 當前詞卡數據
|
||||||
|
|
||||||
// localStorage進度保存和載入
|
// localStorage進度保存和載入
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 載入保存的進度
|
// 載入保存的進度
|
||||||
const savedProgress = localStorage.getItem('review-progress')
|
const savedProgress = localStorage.getItem('review-linear-progress')
|
||||||
if (savedProgress) {
|
if (savedProgress) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(savedProgress)
|
const parsed = JSON.parse(savedProgress)
|
||||||
|
|
@ -131,20 +129,20 @@ export function useReviewSession() {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const isToday = saveTime.toDateString() === now.toDateString()
|
const isToday = saveTime.toDateString() === now.toDateString()
|
||||||
|
|
||||||
if (isToday && parsed.cards) {
|
if (isToday && parsed.testItems) {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'LOAD_PROGRESS',
|
type: 'LOAD_PROGRESS',
|
||||||
payload: {
|
payload: {
|
||||||
cards: parsed.cards,
|
testItems: parsed.testItems,
|
||||||
score: parsed.score || { correct: 0, total: 0 },
|
score: parsed.score || { correct: 0, total: 0 },
|
||||||
isComplete: parsed.isComplete || false
|
isComplete: parsed.isComplete || false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
console.log('📖 載入保存的複習進度')
|
console.log('📖 載入保存的線性複習進度')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('進度載入失敗:', error)
|
console.warn('進度載入失敗:', error)
|
||||||
localStorage.removeItem('review-progress')
|
localStorage.removeItem('review-linear-progress')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
@ -152,55 +150,69 @@ export function useReviewSession() {
|
||||||
// 保存進度到localStorage
|
// 保存進度到localStorage
|
||||||
const saveProgress = () => {
|
const saveProgress = () => {
|
||||||
const progress = {
|
const progress = {
|
||||||
cards,
|
testItems,
|
||||||
score,
|
score,
|
||||||
isComplete,
|
isComplete,
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
}
|
}
|
||||||
localStorage.setItem('review-progress', JSON.stringify(progress))
|
localStorage.setItem('review-linear-progress', JSON.stringify(progress))
|
||||||
console.log('💾 進度已保存')
|
console.log('💾 線性進度已保存')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 處理答題 - 使用 dispatch 統一管理
|
// 處理測驗項目答題
|
||||||
const handleAnswer = (confidence: number) => {
|
const handleAnswer = (confidence: number) => {
|
||||||
if (!currentCard) return
|
if (!currentTestItem) return
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'ANSWER_CARD',
|
type: 'ANSWER_TEST_ITEM',
|
||||||
payload: { cardId: currentCard.id, confidence }
|
payload: { testItemId: currentTestItem.id, confidence }
|
||||||
})
|
})
|
||||||
|
|
||||||
// 保存進度
|
// 保存進度
|
||||||
setTimeout(() => saveProgress(), 100) // 延遲一點確保狀態更新
|
setTimeout(() => saveProgress(), 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 處理跳過 - 使用 dispatch 統一管理
|
// 處理測驗項目跳過
|
||||||
const handleSkip = () => {
|
const handleSkip = () => {
|
||||||
if (!currentCard) return
|
if (!currentTestItem) return
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'SKIP_CARD',
|
type: 'SKIP_TEST_ITEM',
|
||||||
payload: { cardId: currentCard.id }
|
payload: { testItemId: currentTestItem.id }
|
||||||
})
|
})
|
||||||
|
|
||||||
// 保存進度
|
// 保存進度
|
||||||
setTimeout(() => saveProgress(), 100) // 延遲一點確保狀態更新
|
setTimeout(() => saveProgress(), 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重新開始 - 重置所有狀態
|
// 重新開始 - 重置所有狀態
|
||||||
const handleRestart = () => {
|
const handleRestart = () => {
|
||||||
dispatch({ type: 'RESTART' })
|
dispatch({ type: 'RESTART' })
|
||||||
localStorage.removeItem('review-progress') // 清除保存的進度
|
localStorage.removeItem('review-linear-progress')
|
||||||
console.log('🔄 複習進度已重置')
|
console.log('🔄 線性複習進度已重置')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 生成詞彙選擇選項 (僅當當前是詞彙選擇測驗時)
|
||||||
|
const vocabOptions = useMemo(() => {
|
||||||
|
if (currentTestItem?.testType === 'vocab-choice' && currentCard) {
|
||||||
|
return generateVocabOptions(currentCard.word, SIMPLE_CARDS)
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}, [currentTestItem, currentCard])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 狀態
|
// 狀態
|
||||||
cards,
|
testItems,
|
||||||
score,
|
score,
|
||||||
isComplete,
|
isComplete,
|
||||||
|
currentTestItem,
|
||||||
currentCard,
|
currentCard,
|
||||||
sortedCards,
|
vocabOptions,
|
||||||
|
sortedTestItems,
|
||||||
|
|
||||||
|
// 計算屬性
|
||||||
|
totalTestItems: testItems.length,
|
||||||
|
completedTestItems: testItems.filter(item => item.isCompleted).length,
|
||||||
|
|
||||||
// 動作
|
// 動作
|
||||||
handleAnswer,
|
handleAnswer,
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,18 @@ export interface CardState extends ApiFlashcard {
|
||||||
originalOrder: number // 原始順序
|
originalOrder: number // 原始順序
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 測驗項目接口 (線性流程核心)
|
||||||
|
export interface TestItem {
|
||||||
|
id: string // 測驗項目ID
|
||||||
|
cardId: string // 所屬詞卡ID
|
||||||
|
testType: 'flip-card' | 'vocab-choice' // 測驗類型
|
||||||
|
isCompleted: boolean // 個別測驗完成狀態
|
||||||
|
skipCount: number // 跳過次數
|
||||||
|
wrongCount: number // 答錯次數
|
||||||
|
order: number // 序列順序
|
||||||
|
cardData: CardState // 詞卡數據引用
|
||||||
|
}
|
||||||
|
|
||||||
export interface ApiResponse {
|
export interface ApiResponse {
|
||||||
success: boolean
|
success: boolean
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -56,7 +68,62 @@ const addStateFields = (flashcard: ApiFlashcard, index: number): CardState => ({
|
||||||
// 提取詞卡數據 (方便組件使用)
|
// 提取詞卡數據 (方便組件使用)
|
||||||
export const SIMPLE_CARDS = MOCK_API_RESPONSE.data.flashcards.map(addStateFields)
|
export const SIMPLE_CARDS = MOCK_API_RESPONSE.data.flashcards.map(addStateFields)
|
||||||
|
|
||||||
// 延遲計數處理函數
|
// 生成線性測驗項目序列
|
||||||
|
export const generateTestItems = (cards: CardState[]): TestItem[] => {
|
||||||
|
const testItems: TestItem[] = []
|
||||||
|
let order = 0
|
||||||
|
|
||||||
|
cards.forEach((card) => {
|
||||||
|
// 為每張詞卡生成兩個測驗項目:先翻卡記憶,再詞彙選擇
|
||||||
|
const flipCardTest: TestItem = {
|
||||||
|
id: `${card.id}-flip-card`,
|
||||||
|
cardId: card.id,
|
||||||
|
testType: 'flip-card',
|
||||||
|
isCompleted: false,
|
||||||
|
skipCount: 0,
|
||||||
|
wrongCount: 0,
|
||||||
|
order: order++,
|
||||||
|
cardData: card
|
||||||
|
}
|
||||||
|
|
||||||
|
const vocabChoiceTest: TestItem = {
|
||||||
|
id: `${card.id}-vocab-choice`,
|
||||||
|
cardId: card.id,
|
||||||
|
testType: 'vocab-choice',
|
||||||
|
isCompleted: false,
|
||||||
|
skipCount: 0,
|
||||||
|
wrongCount: 0,
|
||||||
|
order: order++,
|
||||||
|
cardData: card
|
||||||
|
}
|
||||||
|
|
||||||
|
testItems.push(flipCardTest, vocabChoiceTest)
|
||||||
|
})
|
||||||
|
|
||||||
|
return testItems
|
||||||
|
}
|
||||||
|
|
||||||
|
// 測驗項目優先級排序 (修正後的延遲計數系統)
|
||||||
|
export const sortTestItemsByPriority = (testItems: TestItem[]): TestItem[] => {
|
||||||
|
return testItems.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.order - b.order
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 舊版排序函數 (保留向後兼容)
|
||||||
export const sortCardsByPriority = (cards: CardState[]): CardState[] => {
|
export const sortCardsByPriority = (cards: CardState[]): CardState[] => {
|
||||||
return cards.sort((a, b) => {
|
return cards.sort((a, b) => {
|
||||||
// 1. 已完成的卡片排到最後
|
// 1. 已完成的卡片排到最後
|
||||||
|
|
@ -75,3 +142,15 @@ export const sortCardsByPriority = (cards: CardState[]): CardState[] => {
|
||||||
return a.originalOrder - b.originalOrder
|
return a.originalOrder - b.originalOrder
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 生成詞彙選擇測驗選項
|
||||||
|
export const generateVocabOptions = (correctWord: string, allCards: CardState[]): string[] => {
|
||||||
|
const allWords = allCards.map(card => card.word).filter(word => word !== correctWord)
|
||||||
|
const shuffledWords = allWords.sort(() => Math.random() - 0.5)
|
||||||
|
const distractors = shuffledWords.slice(0, 3)
|
||||||
|
const options = [correctWord, ...distractors]
|
||||||
|
return options.sort(() => Math.random() - 0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化測驗項目列表
|
||||||
|
export const INITIAL_TEST_ITEMS = generateTestItems(SIMPLE_CARDS)
|
||||||
|
|
|
||||||
|
|
@ -40,21 +40,78 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎯 **階段2: 雙模式版本 (待用戶驗證)**
|
## 🎯 **階段2: 線性雙測驗流程**
|
||||||
|
|
||||||
### **US-002: 詞彙選擇測試**
|
### **US-002: 線性複習流程**
|
||||||
**作為** 想要客觀測試的學習者
|
**作為** 想要高效複習的學習者
|
||||||
**我希望** 能夠通過選擇題測試詞彙記憶
|
**我希望** 系統自動安排複習順序和測驗類型
|
||||||
**以便** 更準確地評估學習成效
|
**以便** 無需思考如何複習,專注於學習本身
|
||||||
|
|
||||||
**觸發條件**:
|
**核心設計原則**:
|
||||||
- ✅ 階段1穩定運行 > 1週
|
- 🔄 **線性流程** - 用戶不需要選擇,系統決定下一步
|
||||||
- ✅ 收到用戶明確反饋需要更多測驗方式
|
- 📚 **固定模式** - 每張詞卡必須完成兩種測驗
|
||||||
|
- ⚡ **自動進行** - 完成一個測驗自動進入下一個
|
||||||
|
|
||||||
### **功能範圍**
|
### **詳細流程規格**
|
||||||
- 4選1選擇題測驗
|
|
||||||
- 模式切換介面
|
#### **2.1 測驗項目生成**
|
||||||
- localStorage進度保存
|
```
|
||||||
|
詞卡A → [翻卡記憶, 詞彙選擇]
|
||||||
|
詞卡B → [翻卡記憶, 詞彙選擇]
|
||||||
|
詞卡C → [翻卡記憶, 詞彙選擇]
|
||||||
|
...
|
||||||
|
|
||||||
|
線性序列: A翻卡 → A選擇 → B翻卡 → B選擇 → C翻卡 → C選擇 → ...
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **2.2 狀態追蹤架構**
|
||||||
|
```typescript
|
||||||
|
interface TestItem {
|
||||||
|
id: string // 測驗項目ID
|
||||||
|
cardId: string // 所屬詞卡ID
|
||||||
|
testType: 'flip-card' | 'vocab-choice'
|
||||||
|
isCompleted: boolean // 個別測驗完成狀態
|
||||||
|
skipCount: number // 跳過次數
|
||||||
|
wrongCount: number // 答錯次數
|
||||||
|
order: number // 序列順序
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **2.3 進度計算邏輯**
|
||||||
|
- **測驗項目總數**: `詞卡數量 × 2`
|
||||||
|
- **當前進度**: `已完成測驗項目數 / 總測驗項目數`
|
||||||
|
- **詞卡完成條件**: 兩個測驗項目都標記為已完成
|
||||||
|
|
||||||
|
#### **2.4 用戶介面流程**
|
||||||
|
1. **進入複習** → 自動開始第一個測驗項目
|
||||||
|
2. **完成測驗** → 自動進入下一個測驗項目
|
||||||
|
3. **全部完成** → 顯示整體結果統計
|
||||||
|
|
||||||
|
#### **2.5 測驗項目詳細設計**
|
||||||
|
|
||||||
|
##### **翻卡記憶測驗**
|
||||||
|
- **目的**: 自我評估對詞彙的熟悉程度
|
||||||
|
- **互動方式**: 點擊翻卡查看定義、例句、同義詞
|
||||||
|
- **評分機制**: 3級信心度 (模糊0分、一般1分、熟悉2分)
|
||||||
|
- **完成條件**: 用戶選擇任意信心度
|
||||||
|
|
||||||
|
##### **詞彙選擇測驗**
|
||||||
|
- **目的**: 客觀測試詞彙記憶準確性
|
||||||
|
- **互動方式**: 4選1選擇題,根據定義選擇正確詞彙
|
||||||
|
- **評分機制**: 正確2分、錯誤0分
|
||||||
|
- **選項生成**: 1個正確答案 + 3個隨機干擾項
|
||||||
|
- **完成條件**: 用戶點擊任意選項
|
||||||
|
|
||||||
|
#### **2.6 延遲計數系統整合**
|
||||||
|
- **跳過行為**: 兩種測驗都支援跳過功能
|
||||||
|
- **錯誤處理**: 翻卡測驗選擇"模糊"、選擇題答錯都計入錯誤
|
||||||
|
- **優先級算法**: 跳過次數 + 錯誤次數 = 延遲分數,越高越優先
|
||||||
|
|
||||||
|
#### **2.7 進度保存機制**
|
||||||
|
- **範圍**: 個別測驗項目的完成狀態
|
||||||
|
- **存儲**: localStorage,包含測驗項目陣列
|
||||||
|
- **恢復**: 頁面重新載入時恢復到正確的測驗項目
|
||||||
|
- **重置**: 跨日期自動重置進度
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -66,6 +123,7 @@
|
||||||
**以便** 學習我感興趣的內容
|
**以便** 學習我感興趣的內容
|
||||||
|
|
||||||
**觸發條件**:
|
**觸發條件**:
|
||||||
|
- ✅ 線性流程驗證成功
|
||||||
- ✅ 有明確需要更多詞彙的用戶反饋
|
- ✅ 有明確需要更多詞彙的用戶反饋
|
||||||
- ✅ 靜態數據已無法滿足需求
|
- ✅ 靜態數據已無法滿足需求
|
||||||
|
|
||||||
|
|
@ -73,16 +131,33 @@
|
||||||
|
|
||||||
## 📊 **成功標準**
|
## 📊 **成功標準**
|
||||||
|
|
||||||
### **MVP階段成功標準**
|
### **階段1 MVP成功標準**
|
||||||
- [ ] 用戶能完整完成複習流程
|
- ✅ 用戶能完整完成翻卡記憶流程
|
||||||
- [ ] 無功能性錯誤或崩潰
|
- ✅ 無功能性錯誤或崩潰
|
||||||
- [ ] 載入時間 < 2秒
|
- ✅ 載入時間 < 2秒
|
||||||
- [ ] 用戶反饋正面
|
- ✅ 延遲計數系統運作正常
|
||||||
|
|
||||||
### **避免的功能**
|
### **階段2 線性流程成功標準**
|
||||||
|
- [ ] 測驗項目自動線性進行,無需用戶選擇
|
||||||
|
- [ ] 每張詞卡的兩種測驗都正確執行
|
||||||
|
- [ ] 延遲計數系統適用於兩種測驗類型
|
||||||
|
- [ ] 進度顯示反映真實的測驗項目完成狀態
|
||||||
|
- [ ] localStorage 保存/恢復測驗項目狀態
|
||||||
|
- [ ] 整體複習完成後顯示統合結果
|
||||||
|
|
||||||
|
### **驗收條件 (Definition of Done)**
|
||||||
|
- [ ] 用戶進入 `/review-simple` 自動開始第一個測驗項目
|
||||||
|
- [ ] 完成翻卡記憶後自動切換到詞彙選擇測驗
|
||||||
|
- [ ] 完成詞彙選擇後自動進入下一張詞卡的翻卡記憶
|
||||||
|
- [ ] 進度條顯示 `已完成測驗項目 / 總測驗項目`
|
||||||
|
- [ ] 延遲計數在兩種測驗間正確傳遞和累積
|
||||||
|
- [ ] 頁面刷新能恢復到正確的測驗項目位置
|
||||||
|
|
||||||
|
### **避免的功能 (Out of Scope)**
|
||||||
|
❌ 用戶手動選擇測驗類型
|
||||||
❌ 智能排程算法
|
❌ 智能排程算法
|
||||||
❌ 複雜狀態管理
|
❌ 複雜狀態管理架構
|
||||||
❌ 多重測驗模式架構
|
❌ 測驗順序自定義
|
||||||
|
|
||||||
**參考**: `技術實作規格.md` 和 `開發控制規範.md` 詳細規定
|
**參考**: `技術實作規格.md` 和 `開發控制規範.md` 詳細規定
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue