440 lines
13 KiB
TypeScript
440 lines
13 KiB
TypeScript
import { useEffect, useState, useCallback } from 'react'
|
||
import { useReviewSessionStore } from '@/store/useReviewSessionStore'
|
||
import { useTestQueueStore } from '@/store/useTestQueueStore'
|
||
import { useTestResultStore } from '@/store/useTestResultStore'
|
||
import { useUIStore } from '@/store/useUIStore'
|
||
import { SmartNavigationController } from './NavigationController'
|
||
import { ProgressBar } from './ProgressBar'
|
||
import { mockFlashcards } from '@/data/mockTestData'
|
||
import {
|
||
FlipMemoryTest,
|
||
VocabChoiceTest,
|
||
SentenceFillTest,
|
||
SentenceReorderTest,
|
||
VocabListeningTest,
|
||
SentenceListeningTest,
|
||
SentenceSpeakingTest
|
||
} from './review-tests'
|
||
|
||
interface TestRunnerProps {
|
||
className?: string
|
||
}
|
||
|
||
export const ReviewRunner: React.FC<TestRunnerProps> = ({ className }) => {
|
||
const { currentCard, error } = useReviewSessionStore()
|
||
const { currentMode, testItems, currentTestIndex, markTestCompleted, goToNextTest, skipCurrentTest } = useTestQueueStore()
|
||
const { updateScore, recordTestResult, score } = useTestResultStore()
|
||
|
||
// 答題狀態管理
|
||
const [hasAnswered, setHasAnswered] = useState(false)
|
||
const [isProcessingAnswer, setIsProcessingAnswer] = useState(false)
|
||
|
||
const {
|
||
openReportModal,
|
||
openImageModal
|
||
} = useUIStore()
|
||
|
||
// 重置答題狀態(切換測驗時)
|
||
useEffect(() => {
|
||
setHasAnswered(false)
|
||
setIsProcessingAnswer(false)
|
||
}, [currentTestIndex, currentMode])
|
||
|
||
// 處理答題(只記錄答案,不進行導航)
|
||
const handleAnswer = useCallback(async (answer: string, confidenceLevel?: number) => {
|
||
if (!currentCard || hasAnswered || isProcessingAnswer) return
|
||
|
||
setIsProcessingAnswer(true)
|
||
|
||
try {
|
||
// 檢查答案正確性
|
||
const isCorrect = checkAnswer(answer, currentCard, currentMode)
|
||
|
||
// 更新分數
|
||
updateScore(isCorrect)
|
||
|
||
// 記錄到後端
|
||
const success = await recordTestResult({
|
||
flashcardId: currentCard.id,
|
||
testType: currentMode,
|
||
isCorrect,
|
||
userAnswer: answer,
|
||
confidenceLevel,
|
||
responseTimeMs: 2000
|
||
})
|
||
|
||
if (success) {
|
||
// 標記測驗為完成
|
||
markTestCompleted(currentTestIndex)
|
||
|
||
// 設定已答題狀態(啟用導航按鈕)
|
||
setHasAnswered(true)
|
||
|
||
// 如果答錯,將題目移到隊列最後(優先級 20)
|
||
if (!isCorrect && currentMode !== 'flip-memory') {
|
||
// TODO: 實現答錯重排邏輯
|
||
console.log('答錯,將重新排入隊列')
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('答題處理失敗:', error)
|
||
} finally {
|
||
setIsProcessingAnswer(false)
|
||
}
|
||
}, [currentCard, hasAnswered, isProcessingAnswer, currentMode, updateScore, recordTestResult, markTestCompleted, currentTestIndex])
|
||
|
||
// 檢查答案正確性
|
||
const checkAnswer = (answer: string, card: any, mode: string): boolean => {
|
||
switch (mode) {
|
||
case 'flip-memory':
|
||
return true // 翻卡記憶沒有對錯,只有信心等級
|
||
|
||
case 'vocab-choice':
|
||
case 'vocab-listening':
|
||
return answer === card.word
|
||
|
||
case 'sentence-fill':
|
||
return answer.toLowerCase().trim() === card.word.toLowerCase()
|
||
|
||
case 'sentence-reorder':
|
||
case 'sentence-listening':
|
||
return answer.toLowerCase().trim() === card.example.toLowerCase().trim()
|
||
|
||
case 'sentence-speaking':
|
||
return true // 口說測驗通常算正確
|
||
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// 生成測驗選項
|
||
const generateOptions = (card: any, mode: string): string[] => {
|
||
// 這裡應該根據測驗類型生成對應的選項
|
||
// 暫時返回簡單的佔位符
|
||
switch (mode) {
|
||
case 'vocab-choice':
|
||
case 'vocab-listening':
|
||
return [card.word, '其他選項1', '其他選項2', '其他選項3'].sort(() => Math.random() - 0.5)
|
||
|
||
case 'sentence-listening':
|
||
return [
|
||
card.example,
|
||
'其他例句選項1',
|
||
'其他例句選項2',
|
||
'其他例句選項3'
|
||
].sort(() => Math.random() - 0.5)
|
||
|
||
default:
|
||
return []
|
||
}
|
||
}
|
||
|
||
// 處理跳過
|
||
const handleSkip = useCallback(() => {
|
||
if (hasAnswered) return // 已答題後不能跳過
|
||
|
||
skipCurrentTest()
|
||
|
||
// 重置狀態準備下一題
|
||
setHasAnswered(false)
|
||
setIsProcessingAnswer(false)
|
||
}, [hasAnswered, skipCurrentTest])
|
||
|
||
// 處理繼續
|
||
const handleContinue = useCallback(() => {
|
||
if (!hasAnswered) return // 未答題不能繼續
|
||
|
||
goToNextTest()
|
||
|
||
// 重置狀態準備下一題
|
||
setHasAnswered(false)
|
||
setIsProcessingAnswer(false)
|
||
}, [hasAnswered, goToNextTest])
|
||
|
||
// 測驗內容渲染函數 (使用 mock 數據)
|
||
const renderTestContentWithMockData = (mockCardData: any, testType: string, options: string[]) => {
|
||
const mockCommonProps = {
|
||
cardData: mockCardData,
|
||
onAnswer: handleAnswer,
|
||
onReportError: () => console.log('Mock report error')
|
||
}
|
||
|
||
switch (testType) {
|
||
case 'flip-memory':
|
||
return (
|
||
<FlipMemoryTest
|
||
{...mockCommonProps}
|
||
onConfidenceSubmit={(level) => handleAnswer('', level)}
|
||
disabled={isProcessingAnswer}
|
||
/>
|
||
)
|
||
|
||
case 'vocab-choice':
|
||
return (
|
||
<VocabChoiceTest
|
||
{...mockCommonProps}
|
||
options={options}
|
||
disabled={isProcessingAnswer}
|
||
/>
|
||
)
|
||
|
||
case 'sentence-fill':
|
||
return (
|
||
<SentenceFillTest
|
||
{...mockCommonProps}
|
||
disabled={isProcessingAnswer}
|
||
/>
|
||
)
|
||
|
||
case 'sentence-reorder':
|
||
return (
|
||
<SentenceReorderTest
|
||
{...mockCommonProps}
|
||
exampleImage={mockCardData.exampleImage}
|
||
onImageClick={(image) => console.log('Mock image click:', image)}
|
||
disabled={isProcessingAnswer}
|
||
/>
|
||
)
|
||
|
||
case 'vocab-listening':
|
||
return (
|
||
<VocabListeningTest
|
||
{...mockCommonProps}
|
||
options={options}
|
||
disabled={isProcessingAnswer}
|
||
/>
|
||
)
|
||
|
||
case 'sentence-listening':
|
||
return (
|
||
<SentenceListeningTest
|
||
{...mockCommonProps}
|
||
options={options}
|
||
exampleImage={mockCardData.exampleImage}
|
||
onImageClick={(image) => console.log('Mock image click:', image)}
|
||
disabled={isProcessingAnswer}
|
||
/>
|
||
)
|
||
|
||
case 'sentence-speaking':
|
||
return (
|
||
<SentenceSpeakingTest
|
||
{...mockCommonProps}
|
||
exampleImage={mockCardData.exampleImage}
|
||
onImageClick={(image) => console.log('Mock image click:', image)}
|
||
disabled={isProcessingAnswer}
|
||
/>
|
||
)
|
||
|
||
default:
|
||
return (
|
||
<div className="text-center py-8">
|
||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-6">
|
||
<h3 className="text-lg font-semibold text-yellow-700 mb-2">未實現的測驗類型</h3>
|
||
<p className="text-yellow-600">測驗類型 "{testType}" 尚未實現</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="text-center py-8">
|
||
<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">{error}</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (!currentCard) {
|
||
// 檢查是否有測試隊列但沒有 currentCard (測試模式)
|
||
if (testItems.length > 0 && currentTestIndex < testItems.length) {
|
||
const currentTest = testItems[currentTestIndex]
|
||
const mockCard = mockFlashcards.find(card => card.id === currentTest.cardId)
|
||
|
||
if (mockCard) {
|
||
// 使用 mock 數據創建 cardData
|
||
const mockCardData = {
|
||
id: mockCard.id,
|
||
word: mockCard.word,
|
||
definition: mockCard.definition,
|
||
example: mockCard.example,
|
||
translation: mockCard.translation,
|
||
exampleTranslation: mockCard.exampleTranslation,
|
||
pronunciation: mockCard.pronunciation,
|
||
difficultyLevel: mockCard.difficultyLevel,
|
||
exampleImage: mockCard.exampleImage,
|
||
synonyms: mockCard.synonyms
|
||
}
|
||
|
||
// 生成測驗選項
|
||
const mockOptions = generateOptions(mockCard, currentTest.testType)
|
||
|
||
return (
|
||
<div className={`review-runner ${className}`}>
|
||
{/* 測驗內容 */}
|
||
<div className="mb-6">
|
||
{renderTestContentWithMockData(mockCardData, currentTest.testType, mockOptions)}
|
||
</div>
|
||
|
||
{/* 智能導航控制器 */}
|
||
<div className="border-t pt-6">
|
||
<SmartNavigationController
|
||
hasAnswered={hasAnswered}
|
||
disabled={isProcessingAnswer}
|
||
onSkipCallback={handleSkip}
|
||
onContinueCallback={handleContinue}
|
||
className="mt-4"
|
||
/>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="text-center py-8">
|
||
<div className="text-gray-500">載入測驗中...</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 共同的 props
|
||
const cardData = {
|
||
id: currentCard.id,
|
||
word: currentCard.word,
|
||
definition: currentCard.definition,
|
||
example: currentCard.example,
|
||
translation: currentCard.translation || '',
|
||
exampleTranslation: currentCard.translation || '',
|
||
pronunciation: currentCard.pronunciation,
|
||
difficultyLevel: currentCard.difficultyLevel || 'A2',
|
||
exampleImage: currentCard.exampleImage,
|
||
synonyms: currentCard.synonyms || []
|
||
}
|
||
|
||
const commonProps = {
|
||
cardData,
|
||
onAnswer: handleAnswer,
|
||
onReportError: () => openReportModal(currentCard)
|
||
}
|
||
|
||
// 測驗內容渲染函數
|
||
const renderTestContent = () => {
|
||
// 渲染對應的測驗組件(不包含導航)
|
||
switch (currentMode) {
|
||
case 'flip-memory':
|
||
return (
|
||
<FlipMemoryTest
|
||
{...commonProps}
|
||
onConfidenceSubmit={(level) => handleAnswer('', level)}
|
||
disabled={isProcessingAnswer}
|
||
/>
|
||
)
|
||
|
||
case 'vocab-choice':
|
||
return (
|
||
<VocabChoiceTest
|
||
{...commonProps}
|
||
options={generateOptions(currentCard, currentMode)}
|
||
disabled={isProcessingAnswer}
|
||
/>
|
||
)
|
||
|
||
case 'sentence-fill':
|
||
return (
|
||
<SentenceFillTest
|
||
{...commonProps}
|
||
disabled={isProcessingAnswer}
|
||
/>
|
||
)
|
||
|
||
case 'sentence-reorder':
|
||
return (
|
||
<SentenceReorderTest
|
||
{...commonProps}
|
||
exampleImage={cardData.exampleImage}
|
||
onImageClick={openImageModal}
|
||
disabled={isProcessingAnswer}
|
||
/>
|
||
)
|
||
|
||
case 'vocab-listening':
|
||
return (
|
||
<VocabListeningTest
|
||
{...commonProps}
|
||
options={generateOptions(currentCard, currentMode)}
|
||
disabled={isProcessingAnswer}
|
||
/>
|
||
)
|
||
|
||
case 'sentence-listening':
|
||
return (
|
||
<SentenceListeningTest
|
||
{...commonProps}
|
||
options={generateOptions(currentCard, currentMode)}
|
||
exampleImage={cardData.exampleImage}
|
||
onImageClick={openImageModal}
|
||
disabled={isProcessingAnswer}
|
||
/>
|
||
)
|
||
|
||
case 'sentence-speaking':
|
||
return (
|
||
<SentenceSpeakingTest
|
||
{...commonProps}
|
||
exampleImage={cardData.exampleImage}
|
||
onImageClick={openImageModal}
|
||
disabled={isProcessingAnswer}
|
||
/>
|
||
)
|
||
|
||
default:
|
||
return (
|
||
<div className="text-center py-8">
|
||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-6">
|
||
<h3 className="text-lg font-semibold text-yellow-700 mb-2">未實現的測驗類型</h3>
|
||
<p className="text-yellow-600">測驗類型 "{currentMode}" 尚未實現</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className={`review-runner ${className}`}>
|
||
{/* 進度條 (僅在測試模式顯示) */}
|
||
{testItems.length > 0 && (
|
||
<div className="mb-6">
|
||
<ProgressBar
|
||
current={currentTestIndex}
|
||
total={testItems.length}
|
||
correct={score.correct}
|
||
incorrect={score.total - score.correct}
|
||
skipped={testItems.filter(item => item.isSkipped).length}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* 測驗內容 */}
|
||
<div className="mb-6">
|
||
{renderTestContent()}
|
||
</div>
|
||
|
||
{/* 智能導航控制器 */}
|
||
<div className="border-t pt-6">
|
||
<SmartNavigationController
|
||
hasAnswered={hasAnswered}
|
||
disabled={isProcessingAnswer}
|
||
onSkipCallback={handleSkip}
|
||
onContinueCallback={handleContinue}
|
||
className="mt-4"
|
||
/>
|
||
</div>
|
||
</div>
|
||
)
|
||
} |