774 lines
26 KiB
TypeScript
774 lines
26 KiB
TypeScript
'use client'
|
||
|
||
import { useState, useEffect } from 'react'
|
||
import { useRouter } from 'next/navigation'
|
||
import { Navigation } from '@/components/Navigation'
|
||
import VoiceRecorder from '@/components/VoiceRecorder'
|
||
import LearningComplete from '@/components/LearningComplete'
|
||
import SegmentedProgressBar from '@/components/SegmentedProgressBar'
|
||
import { studySessionService, type StudySession, type CurrentTest, type Progress } from '@/lib/services/studySession'
|
||
|
||
export default function NewLearnPage() {
|
||
const router = useRouter()
|
||
const [mounted, setMounted] = useState(false)
|
||
|
||
// 會話狀態
|
||
const [session, setSession] = useState<StudySession | null>(null)
|
||
const [currentTest, setCurrentTest] = useState<CurrentTest | null>(null)
|
||
const [progress, setProgress] = useState<Progress | null>(null)
|
||
|
||
// UI狀態
|
||
const [isLoading, setIsLoading] = useState(false)
|
||
const [selectedAnswer, setSelectedAnswer] = useState<string | null>(null)
|
||
const [showResult, setShowResult] = useState(false)
|
||
const [fillAnswer, setFillAnswer] = useState('')
|
||
const [showHint, setShowHint] = useState(false)
|
||
const [showComplete, setShowComplete] = useState(false)
|
||
const [showTaskListModal, setShowTaskListModal] = useState(false)
|
||
|
||
// 例句重組狀態
|
||
const [shuffledWords, setShuffledWords] = useState<string[]>([])
|
||
const [arrangedWords, setArrangedWords] = useState<string[]>([])
|
||
const [reorderResult, setReorderResult] = useState<boolean | null>(null)
|
||
|
||
// 分數狀態
|
||
const [score, setScore] = useState({ correct: 0, total: 0 })
|
||
|
||
// Client-side mounting
|
||
useEffect(() => {
|
||
setMounted(true)
|
||
startNewSession()
|
||
}, [])
|
||
|
||
// 開始新的學習會話
|
||
const startNewSession = async () => {
|
||
try {
|
||
setIsLoading(true)
|
||
console.log('🎯 開始新的學習會話...')
|
||
|
||
const sessionResult = await studySessionService.startSession()
|
||
if (sessionResult.success && sessionResult.data) {
|
||
const newSession = sessionResult.data
|
||
setSession(newSession)
|
||
console.log('✅ 學習會話創建成功:', newSession)
|
||
|
||
// 載入第一個測驗和詳細進度
|
||
await loadCurrentTest(newSession.sessionId)
|
||
await loadProgress(newSession.sessionId)
|
||
} else {
|
||
console.error('❌ 創建學習會話失敗:', sessionResult.error)
|
||
if (sessionResult.error === 'No due cards available for study') {
|
||
setShowComplete(true)
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('💥 創建學習會話異常:', error)
|
||
} finally {
|
||
setIsLoading(false)
|
||
}
|
||
}
|
||
|
||
// 載入當前測驗
|
||
const loadCurrentTest = async (sessionId: string) => {
|
||
try {
|
||
const testResult = await studySessionService.getCurrentTest(sessionId)
|
||
if (testResult.success && testResult.data) {
|
||
setCurrentTest(testResult.data)
|
||
resetTestStates()
|
||
console.log('🎯 載入當前測驗:', testResult.data.testType, 'for', testResult.data.card.word)
|
||
} else {
|
||
console.error('❌ 載入測驗失敗:', testResult.error)
|
||
}
|
||
} catch (error) {
|
||
console.error('💥 載入測驗異常:', error)
|
||
}
|
||
}
|
||
|
||
// 載入詳細進度
|
||
const loadProgress = async (sessionId: string) => {
|
||
try {
|
||
const progressResult = await studySessionService.getProgress(sessionId)
|
||
if (progressResult.success && progressResult.data) {
|
||
setProgress(progressResult.data)
|
||
console.log('📊 載入進度成功:', progressResult.data)
|
||
} else {
|
||
console.error('❌ 載入進度失敗:', progressResult.error)
|
||
}
|
||
} catch (error) {
|
||
console.error('💥 載入進度異常:', error)
|
||
}
|
||
}
|
||
|
||
// 提交測驗結果
|
||
const submitTest = async (isCorrect: boolean, userAnswer?: string, confidenceLevel?: number) => {
|
||
if (!session || !currentTest) return
|
||
|
||
try {
|
||
const result = await studySessionService.submitTest(session.sessionId, {
|
||
testType: currentTest.testType,
|
||
isCorrect,
|
||
userAnswer,
|
||
confidenceLevel,
|
||
responseTimeMs: 2000 // 簡化時間計算
|
||
})
|
||
|
||
if (result.success && result.data) {
|
||
console.log('✅ 測驗結果提交成功:', result.data)
|
||
|
||
// 更新分數
|
||
setScore(prev => ({
|
||
correct: isCorrect ? prev.correct + 1 : prev.correct,
|
||
total: prev.total + 1
|
||
}))
|
||
|
||
// 更新本地進度顯示
|
||
if (progress && result.data) {
|
||
setProgress(prev => prev ? {
|
||
...prev,
|
||
completedTests: result.data!.progress.completedTests,
|
||
completedCards: result.data!.progress.completedCards
|
||
} : null)
|
||
}
|
||
|
||
// 重新載入完整進度數據
|
||
await loadProgress(session.sessionId)
|
||
|
||
// 檢查是否有下一個測驗
|
||
setTimeout(async () => {
|
||
await loadNextTest()
|
||
}, 1500) // 顯示結果1.5秒後自動進入下一題
|
||
|
||
} else {
|
||
console.error('❌ 提交測驗結果失敗:', result.error)
|
||
}
|
||
} catch (error) {
|
||
console.error('💥 提交測驗結果異常:', error)
|
||
}
|
||
}
|
||
|
||
// 載入下一個測驗
|
||
const loadNextTest = async () => {
|
||
if (!session) return
|
||
|
||
try {
|
||
const nextResult = await studySessionService.getNextTest(session.sessionId)
|
||
if (nextResult.success && nextResult.data) {
|
||
const nextTest = nextResult.data
|
||
|
||
if (nextTest.hasNextTest) {
|
||
// 載入下一個測驗
|
||
await loadCurrentTest(session.sessionId)
|
||
} else {
|
||
// 會話完成
|
||
console.log('🎉 學習會話完成!')
|
||
await studySessionService.completeSession(session.sessionId)
|
||
setShowComplete(true)
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('💥 載入下一個測驗異常:', error)
|
||
}
|
||
}
|
||
|
||
// 重置測驗狀態
|
||
const resetTestStates = () => {
|
||
setSelectedAnswer(null)
|
||
setShowResult(false)
|
||
setFillAnswer('')
|
||
setShowHint(false)
|
||
setShuffledWords([])
|
||
setArrangedWords([])
|
||
setReorderResult(null)
|
||
}
|
||
|
||
// 測驗處理函數
|
||
const handleQuizAnswer = async (answer: string) => {
|
||
if (showResult || !currentTest) return
|
||
|
||
setSelectedAnswer(answer)
|
||
setShowResult(true)
|
||
|
||
const isCorrect = answer === currentTest.card.word
|
||
await submitTest(isCorrect, answer)
|
||
}
|
||
|
||
const handleFillAnswer = async () => {
|
||
if (showResult || !currentTest) return
|
||
|
||
setShowResult(true)
|
||
const isCorrect = fillAnswer.toLowerCase().trim() === currentTest.card.word.toLowerCase()
|
||
await submitTest(isCorrect, fillAnswer)
|
||
}
|
||
|
||
const handleConfidenceLevel = async (level: number) => {
|
||
if (!currentTest) return
|
||
await submitTest(true, undefined, level) // 翻卡記憶以信心等級為準
|
||
}
|
||
|
||
const handleReorderAnswer = async () => {
|
||
if (!currentTest) return
|
||
|
||
const userSentence = arrangedWords.join(' ')
|
||
const correctSentence = currentTest.card.example
|
||
const isCorrect = userSentence.toLowerCase().trim() === correctSentence.toLowerCase().trim()
|
||
|
||
setReorderResult(isCorrect)
|
||
setShowResult(true)
|
||
|
||
await submitTest(isCorrect, userSentence)
|
||
}
|
||
|
||
const handleSpeakingAnswer = async (transcript: string) => {
|
||
if (!currentTest) return
|
||
|
||
setShowResult(true)
|
||
const isCorrect = transcript.toLowerCase().includes(currentTest.card.word.toLowerCase())
|
||
await submitTest(isCorrect, transcript)
|
||
}
|
||
|
||
// 初始化例句重組
|
||
useEffect(() => {
|
||
if (currentTest && currentTest.testType === 'sentence-reorder') {
|
||
const words = currentTest.card.example.split(/\s+/).filter(word => word.length > 0)
|
||
const shuffled = [...words].sort(() => Math.random() - 0.5)
|
||
setShuffledWords(shuffled)
|
||
setArrangedWords([])
|
||
setReorderResult(null)
|
||
}
|
||
}, [currentTest])
|
||
|
||
// 例句重組處理
|
||
const handleWordClick = (word: string) => {
|
||
setShuffledWords(prev => prev.filter(w => w !== word))
|
||
setArrangedWords(prev => [...prev, word])
|
||
setReorderResult(null)
|
||
}
|
||
|
||
const handleRemoveFromArranged = (word: string) => {
|
||
setArrangedWords(prev => prev.filter(w => w !== word))
|
||
setShuffledWords(prev => [...prev, word])
|
||
setReorderResult(null)
|
||
}
|
||
|
||
const handleResetReorder = () => {
|
||
if (!currentTest) return
|
||
const words = currentTest.card.example.split(/\s+/).filter(word => word.length > 0)
|
||
const shuffled = [...words].sort(() => Math.random() - 0.5)
|
||
setShuffledWords(shuffled)
|
||
setArrangedWords([])
|
||
setReorderResult(null)
|
||
}
|
||
|
||
// 重新開始
|
||
const handleRestart = async () => {
|
||
setScore({ correct: 0, total: 0 })
|
||
setShowComplete(false)
|
||
await startNewSession()
|
||
}
|
||
|
||
// Loading screen
|
||
if (!mounted || isLoading) {
|
||
return (
|
||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center">
|
||
<div className="text-gray-500 text-lg">載入中...</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// No session or complete
|
||
if (!session || showComplete) {
|
||
return (
|
||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
|
||
<Navigation />
|
||
<div className="max-w-4xl mx-auto px-4 py-8 flex items-center justify-center min-h-[calc(100vh-80px)]">
|
||
{showComplete ? (
|
||
<LearningComplete
|
||
score={score}
|
||
mode="mixed"
|
||
onRestart={handleRestart}
|
||
onBackToDashboard={() => router.push('/dashboard')}
|
||
/>
|
||
) : (
|
||
<div className="bg-white rounded-xl p-8 max-w-md w-full text-center shadow-lg">
|
||
<div className="text-6xl mb-4">📚</div>
|
||
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
||
今日學習已完成!
|
||
</h2>
|
||
<p className="text-gray-600 mb-6">
|
||
目前沒有到期需要複習的詞卡。
|
||
</p>
|
||
<div className="flex gap-3">
|
||
<button
|
||
onClick={() => router.push('/flashcards')}
|
||
className="flex-1 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors font-medium"
|
||
>
|
||
管理詞卡
|
||
</button>
|
||
<button
|
||
onClick={() => router.push('/dashboard')}
|
||
className="flex-1 py-3 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors font-medium"
|
||
>
|
||
回到首頁
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// No current test
|
||
if (!currentTest) {
|
||
return (
|
||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center">
|
||
<div className="text-gray-500 text-lg">載入測驗中...</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
|
||
<Navigation />
|
||
|
||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||
{/* 分段式進度條 */}
|
||
<div className="mb-8">
|
||
<div className="flex justify-between items-center mb-3">
|
||
<span className="text-sm font-medium text-gray-900">學習進度</span>
|
||
<button
|
||
onClick={() => setShowTaskListModal(true)}
|
||
className="text-sm text-gray-600 hover:text-blue-600 transition-colors cursor-pointer flex items-center gap-1"
|
||
title="點擊查看詳細進度"
|
||
>
|
||
詳細進度 📋
|
||
</button>
|
||
</div>
|
||
|
||
{progress && (
|
||
<SegmentedProgressBar
|
||
progress={progress}
|
||
onClick={() => setShowTaskListModal(true)}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{/* 測驗內容渲染 */}
|
||
{currentTest.testType === 'flip-memory' && (
|
||
<FlipMemoryTest
|
||
card={currentTest.card}
|
||
onConfidenceSelect={handleConfidenceLevel}
|
||
showResult={showResult}
|
||
/>
|
||
)}
|
||
|
||
{currentTest.testType === 'vocab-choice' && (
|
||
<VocabChoiceTest
|
||
card={currentTest.card}
|
||
onAnswer={handleQuizAnswer}
|
||
selectedAnswer={selectedAnswer}
|
||
showResult={showResult}
|
||
/>
|
||
)}
|
||
|
||
{currentTest.testType === 'sentence-fill' && (
|
||
<SentenceFillTest
|
||
card={currentTest.card}
|
||
fillAnswer={fillAnswer}
|
||
setFillAnswer={setFillAnswer}
|
||
onSubmit={handleFillAnswer}
|
||
showHint={showHint}
|
||
setShowHint={setShowHint}
|
||
showResult={showResult}
|
||
/>
|
||
)}
|
||
|
||
{currentTest.testType === 'sentence-reorder' && (
|
||
<SentenceReorderTest
|
||
card={currentTest.card}
|
||
shuffledWords={shuffledWords}
|
||
arrangedWords={arrangedWords}
|
||
onWordClick={handleWordClick}
|
||
onRemoveWord={handleRemoveFromArranged}
|
||
onCheckAnswer={handleReorderAnswer}
|
||
onReset={handleResetReorder}
|
||
showResult={showResult}
|
||
result={reorderResult}
|
||
/>
|
||
)}
|
||
|
||
{currentTest.testType === 'sentence-speaking' && (
|
||
<SentenceSpeakingTest
|
||
card={currentTest.card}
|
||
onComplete={handleSpeakingAnswer}
|
||
showResult={showResult}
|
||
/>
|
||
)}
|
||
|
||
{/* 任務清單模態框 */}
|
||
{showTaskListModal && progress && (
|
||
<TaskListModal
|
||
progress={progress}
|
||
onClose={() => setShowTaskListModal(false)}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 測驗組件定義
|
||
|
||
interface TestComponentProps {
|
||
card: any
|
||
showResult: boolean
|
||
}
|
||
|
||
function FlipMemoryTest({ card, onConfidenceSelect, showResult }: TestComponentProps & {
|
||
onConfidenceSelect: (level: number) => void
|
||
}) {
|
||
const [isFlipped, setIsFlipped] = useState(false)
|
||
|
||
return (
|
||
<div className="bg-white rounded-xl shadow-lg p-8">
|
||
<h2 className="text-2xl font-bold text-gray-900 mb-6">翻卡記憶</h2>
|
||
|
||
<div className="text-center mb-8" onClick={() => setIsFlipped(!isFlipped)}>
|
||
{!isFlipped ? (
|
||
<div className="bg-gray-50 rounded-lg p-8 cursor-pointer">
|
||
<h3 className="text-4xl font-bold text-gray-900 mb-4">{card.word}</h3>
|
||
<p className="text-gray-500">{card.pronunciation}</p>
|
||
</div>
|
||
) : (
|
||
<div className="bg-gray-50 rounded-lg p-8">
|
||
<p className="text-xl text-gray-700 mb-4">{card.definition}</p>
|
||
<p className="text-lg text-gray-600 italic">"{card.example}"</p>
|
||
<p className="text-sm text-gray-500 mt-2">"{card.exampleTranslation}"</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{isFlipped && !showResult && (
|
||
<div className="flex gap-2 justify-center">
|
||
{[1, 2, 3, 4, 5].map(level => (
|
||
<button
|
||
key={level}
|
||
onClick={() => onConfidenceSelect(level)}
|
||
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors"
|
||
>
|
||
{level}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function VocabChoiceTest({ card, onAnswer, selectedAnswer, showResult }: TestComponentProps & {
|
||
onAnswer: (answer: string) => void
|
||
selectedAnswer: string | null
|
||
}) {
|
||
const options = [card.word, 'example1', 'example2', 'example3'].sort(() => Math.random() - 0.5)
|
||
|
||
return (
|
||
<div className="bg-white rounded-xl shadow-lg p-8">
|
||
<h2 className="text-2xl font-bold text-gray-900 mb-6">詞彙選擇</h2>
|
||
|
||
<div className="mb-6">
|
||
<p className="text-lg text-gray-700">{card.definition}</p>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
{options.map((option, idx) => (
|
||
<button
|
||
key={idx}
|
||
onClick={() => !showResult && onAnswer(option)}
|
||
disabled={showResult}
|
||
className={`w-full p-4 text-left rounded-lg border-2 transition-all ${
|
||
showResult
|
||
? option === card.word
|
||
? 'border-green-500 bg-green-50 text-green-700'
|
||
: option === selectedAnswer
|
||
? 'border-red-500 bg-red-50 text-red-700'
|
||
: 'border-gray-200 bg-gray-50 text-gray-500'
|
||
: 'border-gray-200 hover:border-blue-300 hover:bg-blue-50'
|
||
}`}
|
||
>
|
||
{option}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{showResult && (
|
||
<div className={`mt-6 p-4 rounded-lg ${
|
||
selectedAnswer === card.word ? 'bg-green-50' : 'bg-red-50'
|
||
}`}>
|
||
<p className={`font-semibold ${
|
||
selectedAnswer === card.word ? 'text-green-700' : 'text-red-700'
|
||
}`}>
|
||
{selectedAnswer === card.word ? '正確!' : '錯誤!'}
|
||
</p>
|
||
{selectedAnswer !== card.word && (
|
||
<p className="text-gray-700 mt-2">
|
||
正確答案: <strong>{card.word}</strong>
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function SentenceFillTest({ card, fillAnswer, setFillAnswer, onSubmit, showHint, setShowHint, showResult }: TestComponentProps & {
|
||
fillAnswer: string
|
||
setFillAnswer: (value: string) => void
|
||
onSubmit: () => void
|
||
showHint: boolean
|
||
setShowHint: (show: boolean) => void
|
||
}) {
|
||
return (
|
||
<div className="bg-white rounded-xl shadow-lg p-8">
|
||
<h2 className="text-2xl font-bold text-gray-900 mb-6">例句填空</h2>
|
||
|
||
<div className="mb-6">
|
||
<div className="bg-gray-50 rounded-lg p-6 text-lg">
|
||
{card.example.split(new RegExp(`(${card.word})`, 'gi')).map((part: string, index: number) => {
|
||
const isTargetWord = part.toLowerCase() === card.word.toLowerCase()
|
||
return isTargetWord ? (
|
||
<input
|
||
key={index}
|
||
type="text"
|
||
value={fillAnswer}
|
||
onChange={(e) => setFillAnswer(e.target.value)}
|
||
placeholder="____"
|
||
disabled={showResult}
|
||
className="inline-block px-2 py-1 mx-1 border-b-2 border-blue-500 focus:outline-none"
|
||
style={{ width: `${Math.max(60, card.word.length * 12)}px` }}
|
||
/>
|
||
) : (
|
||
<span key={index}>{part}</span>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex gap-3 mb-4">
|
||
{!showResult && fillAnswer.trim() && (
|
||
<button
|
||
onClick={onSubmit}
|
||
className="px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors"
|
||
>
|
||
確認答案
|
||
</button>
|
||
)}
|
||
<button
|
||
onClick={() => setShowHint(!showHint)}
|
||
className="px-6 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors"
|
||
>
|
||
{showHint ? '隱藏提示' : '顯示提示'}
|
||
</button>
|
||
</div>
|
||
|
||
{showHint && (
|
||
<div className="mb-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||
<p className="text-yellow-800">{card.definition}</p>
|
||
</div>
|
||
)}
|
||
|
||
{showResult && (
|
||
<div className={`mt-6 p-4 rounded-lg ${
|
||
fillAnswer.toLowerCase().trim() === card.word.toLowerCase() ? 'bg-green-50' : 'bg-red-50'
|
||
}`}>
|
||
<p className={`font-semibold ${
|
||
fillAnswer.toLowerCase().trim() === card.word.toLowerCase() ? 'text-green-700' : 'text-red-700'
|
||
}`}>
|
||
{fillAnswer.toLowerCase().trim() === card.word.toLowerCase() ? '正確!' : '錯誤!'}
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function SentenceReorderTest({ card, shuffledWords, arrangedWords, onWordClick, onRemoveWord, onCheckAnswer, onReset, showResult, result }: TestComponentProps & {
|
||
shuffledWords: string[]
|
||
arrangedWords: string[]
|
||
onWordClick: (word: string) => void
|
||
onRemoveWord: (word: string) => void
|
||
onCheckAnswer: () => void
|
||
onReset: () => void
|
||
result: boolean | null
|
||
}) {
|
||
return (
|
||
<div className="bg-white rounded-xl shadow-lg p-8">
|
||
<h2 className="text-2xl font-bold text-gray-900 mb-6">例句重組</h2>
|
||
|
||
{/* 重組區域 */}
|
||
<div className="mb-6">
|
||
<h3 className="text-lg font-semibold text-gray-900 mb-3">重組區域:</h3>
|
||
<div className="min-h-[80px] bg-gray-50 rounded-lg p-4 border-2 border-dashed border-gray-300">
|
||
{arrangedWords.length === 0 ? (
|
||
<div className="flex items-center justify-center h-full text-gray-400">
|
||
將單字拖到這裡
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-wrap gap-2">
|
||
{arrangedWords.map((word, index) => (
|
||
<button
|
||
key={index}
|
||
onClick={() => onRemoveWord(word)}
|
||
className="bg-blue-100 text-blue-800 px-3 py-2 rounded-full hover:bg-blue-200"
|
||
>
|
||
{word} ×
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 可用單字 */}
|
||
<div className="mb-6">
|
||
<h3 className="text-lg font-semibold text-gray-900 mb-3">可用單字:</h3>
|
||
<div className="bg-white border border-gray-200 rounded-lg p-4 min-h-[60px]">
|
||
<div className="flex flex-wrap gap-2">
|
||
{shuffledWords.map((word, index) => (
|
||
<button
|
||
key={index}
|
||
onClick={() => onWordClick(word)}
|
||
className="bg-gray-100 text-gray-800 px-3 py-2 rounded-full hover:bg-gray-200"
|
||
>
|
||
{word}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex gap-3 mb-6">
|
||
{arrangedWords.length > 0 && !showResult && (
|
||
<button
|
||
onClick={onCheckAnswer}
|
||
className="px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors"
|
||
>
|
||
檢查答案
|
||
</button>
|
||
)}
|
||
<button
|
||
onClick={onReset}
|
||
className="px-6 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors"
|
||
>
|
||
重新開始
|
||
</button>
|
||
</div>
|
||
|
||
{result !== null && (
|
||
<div className={`p-4 rounded-lg ${result ? 'bg-green-50' : 'bg-red-50'}`}>
|
||
<p className={`font-semibold ${result ? 'text-green-700' : 'text-red-700'}`}>
|
||
{result ? '正確!' : '錯誤!'}
|
||
</p>
|
||
{!result && (
|
||
<p className="text-gray-700 mt-2">
|
||
正確答案: <strong>"{card.example}"</strong>
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function SentenceSpeakingTest({ card, onComplete, showResult }: TestComponentProps & {
|
||
onComplete: (transcript: string) => void
|
||
}) {
|
||
return (
|
||
<div className="bg-white rounded-xl shadow-lg p-8">
|
||
<h2 className="text-2xl font-bold text-gray-900 mb-6">例句口說</h2>
|
||
|
||
<VoiceRecorder
|
||
targetText={card.example}
|
||
targetTranslation={card.exampleTranslation}
|
||
instructionText="請大聲說出完整的例句:"
|
||
onRecordingComplete={(_audioBlob) => onComplete(card.example)}
|
||
/>
|
||
|
||
{showResult && (
|
||
<div className="mt-6 p-4 rounded-lg bg-blue-50">
|
||
<p className="text-blue-700 font-semibold">錄音完成!</p>
|
||
<p className="text-gray-600">系統正在評估發音...</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function TaskListModal({ progress, onClose }: {
|
||
progress: Progress
|
||
onClose: () => void
|
||
}) {
|
||
return (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||
<div className="bg-white rounded-xl shadow-2xl max-w-4xl w-full mx-4 max-h-[80vh] overflow-hidden">
|
||
<div className="flex items-center justify-between p-6 border-b">
|
||
<h2 className="text-2xl font-bold text-gray-900">📚 學習進度</h2>
|
||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-2xl">✕</button>
|
||
</div>
|
||
|
||
<div className="p-6 overflow-y-auto max-h-[60vh]">
|
||
<div className="mb-6 bg-blue-50 rounded-lg p-4">
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-blue-900 font-medium">
|
||
整體進度: {progress.completedTests} / {progress.totalTests}
|
||
({Math.round((progress.completedTests / progress.totalTests) * 100)}%)
|
||
</span>
|
||
<span className="text-blue-800">
|
||
詞卡: {progress.completedCards} / {progress.totalCards}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
{progress.cards.map((card, index) => (
|
||
<div key={card.cardId} className="border rounded-lg p-4">
|
||
<div className="flex items-center gap-3 mb-3">
|
||
<span className="font-medium">詞卡{index + 1}: {card.word}</span>
|
||
<span className={`text-xs px-2 py-1 rounded ${
|
||
card.isCompleted ? 'bg-green-100 text-green-700' : 'bg-blue-100 text-blue-700'
|
||
}`}>
|
||
{card.completedTestsCount}/{card.plannedTests.length} 測驗
|
||
</span>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||
{card.plannedTests.map(testType => {
|
||
const isCompleted = card.tests.some(t => t.testType === testType)
|
||
return (
|
||
<div
|
||
key={testType}
|
||
className={`p-2 rounded text-sm ${
|
||
isCompleted ? 'bg-green-50 text-green-700' : 'bg-gray-50 text-gray-500'
|
||
}`}
|
||
>
|
||
{isCompleted ? '✅' : '⚪'} {testType}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="px-6 py-4 border-t bg-gray-50">
|
||
<button
|
||
onClick={onClose}
|
||
className="w-full py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors"
|
||
>
|
||
關閉
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
} |