dramaling-vocab-learning/frontend/components/review/quiz/SentenceSpeakingQuiz.tsx

294 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useState, useCallback } from 'react'
import { CardState } from '@/lib/data/reviewSimpleData'
import { QuizHeader } from '../ui/QuizHeader'
import { AudioRecorder } from '@/components/shared/AudioRecorder'
import { speechAssessmentService, PronunciationResult } from '@/lib/services/speechAssessment'
import { getFlashcardImageUrl } from '@/lib/utils/flashcardUtils'
interface SentenceSpeakingQuizProps {
card: CardState
onAnswer: (confidence: number) => void
onSkip: () => void
}
export function SentenceSpeakingQuiz({ card, onAnswer, onSkip }: SentenceSpeakingQuizProps) {
const [assessmentResult, setAssessmentResult] = useState<PronunciationResult | null>(null)
const [isEvaluating, setIsEvaluating] = useState(false)
const [hasAnswered, setHasAnswered] = useState(false)
const [error, setError] = useState<string | null>(null)
// 獲取例句圖片 - 多重來源檢查
const exampleImageUrl = getFlashcardImageUrl(card) ||
(card as any).PrimaryImageUrl ||
card.primaryImageUrl ||
null
// 除錯資訊 - 檢查圖片資料
console.log('🔍 SentenceSpeaking 圖片除錯:', {
cardId: card.id,
word: card.word,
hasExampleImage: card.hasExampleImage,
primaryImageUrl: card.primaryImageUrl,
PrimaryImageUrl: (card as any).PrimaryImageUrl,
exampleImages: (card as any).exampleImages,
FlashcardExampleImages: (card as any).FlashcardExampleImages,
originalCard: card,
computedImageUrl: exampleImageUrl
})
// 處理錄音完成
const handleRecordingComplete = useCallback(async (audioBlob: Blob) => {
if (hasAnswered) return
setIsEvaluating(true)
setError(null)
try {
console.log('🎤 開始發音評估...', {
flashcardId: card.id,
referenceText: card.example,
audioSize: `${(audioBlob.size / 1024).toFixed(1)} KB`
})
const response = await speechAssessmentService.evaluatePronunciation(
audioBlob,
card.example,
card.id,
'en-US'
)
if (response.success && response.data) {
setAssessmentResult(response.data)
setHasAnswered(true)
// 稍後自動提交結果(給用戶時間查看評分)
setTimeout(() => {
onAnswer(response.data!.confidenceLevel)
}, 2000)
} else {
throw new Error(response.error || '發音評估失敗')
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '評估過程發生錯誤'
setError(errorMessage)
console.error('發音評估錯誤:', error)
} finally {
setIsEvaluating(false)
}
}, [hasAnswered, card.id, card.example, onAnswer])
// 處理跳過
const handleSkip = useCallback(() => {
onSkip()
}, [onSkip])
// 手動提交結果(如果自動提交被取消)
const handleSubmitResult = useCallback(() => {
if (assessmentResult && hasAnswered) {
onAnswer(assessmentResult.confidenceLevel)
}
}, [assessmentResult, hasAnswered, onAnswer])
// 重新錄音
const handleRetry = useCallback(() => {
setAssessmentResult(null)
setHasAnswered(false)
setError(null)
}, [])
return (
<div className="bg-white rounded-xl shadow-lg p-6">
<QuizHeader
title="例句口說練習"
cefr={card.cefr || (card as any).CEFR || 'A1'}
/>
<div className="space-y-6">
{/* 說明文字 */}
<div className="text-center bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-blue-800 font-medium">
📸 AI
</p>
</div>
{/* 例句圖片 - 更顯著的顯示 */}
{exampleImageUrl ? (
<div className="text-center">
<div className="bg-gray-50 border border-gray-200 rounded-xl p-6 mb-4">
<img
src={exampleImageUrl}
alt={`${card.word} example illustration`}
className="w-full max-w-lg mx-auto rounded-lg shadow-sm"
style={{ maxHeight: '300px', objectFit: 'contain' }}
/>
</div>
<p className="text-gray-500 text-sm mb-6">
💡
</p>
</div>
) : (
<div className="text-center bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
<p className="text-yellow-800 text-sm">
</p>
</div>
)}
{/* 目標例句 */}
<div className="bg-gradient-to-r from-green-50 to-blue-50 border border-green-200 rounded-lg p-6">
<div className="text-center mb-4">
<h4 className="text-lg font-semibold text-gray-900 mb-2"></h4>
<p className="text-2xl text-gray-900 font-medium leading-relaxed mb-3">
{card.example}
</p>
<p className="text-gray-600 text-base">
{card.exampleTranslation}
</p>
</div>
</div>
{/* 錄音區域 */}
{!hasAnswered && !isEvaluating && (
<div>
<AudioRecorder
onRecordingComplete={handleRecordingComplete}
onError={setError}
maxDuration={30}
className="mb-4"
/>
<p className="text-gray-500 text-sm text-center mb-4">
💡 調
</p>
</div>
)}
{/* 評估中狀態 */}
{isEvaluating && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-3"></div>
<h4 className="font-semibold text-blue-900 mb-2">AI ...</h4>
<p className="text-blue-700 text-sm">
調 ( 2-3 )
</p>
</div>
)}
{/* 錯誤顯示 */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<h4 className="font-semibold text-red-900 mb-2"> </h4>
<p className="text-red-700 text-sm mb-3">{error}</p>
<button
onClick={handleRetry}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm"
>
</button>
</div>
)}
{/* 評估結果顯示 */}
{assessmentResult && hasAnswered && (
<div className="bg-green-50 border border-green-200 rounded-lg p-6">
<h4 className="font-semibold text-green-900 mb-4">🎉 </h4>
{/* 總分顯示 */}
<div className="flex items-center justify-center gap-4 mb-4">
<div className="text-center">
<div className="text-4xl font-bold text-green-600 mb-1">
{Math.round(assessmentResult.scores.overall)}
</div>
<div className="text-green-700 text-sm font-medium"></div>
</div>
<div className="text-green-600 text-lg">/ 100</div>
</div>
{/* 詳細評分 */}
<div className="grid grid-cols-2 gap-3 mb-4">
<div className="bg-white p-3 rounded border">
<div className="text-xs text-gray-600 mb-1"></div>
<div className="font-semibold text-lg text-gray-900">
{Math.round(assessmentResult.scores.accuracy)}
</div>
</div>
<div className="bg-white p-3 rounded border">
<div className="text-xs text-gray-600 mb-1"></div>
<div className="font-semibold text-lg text-gray-900">
{Math.round(assessmentResult.scores.fluency)}
</div>
</div>
<div className="bg-white p-3 rounded border">
<div className="text-xs text-gray-600 mb-1"></div>
<div className="font-semibold text-lg text-gray-900">
{Math.round(assessmentResult.scores.completeness)}
</div>
</div>
<div className="bg-white p-3 rounded border">
<div className="text-xs text-gray-600 mb-1">調</div>
<div className="font-semibold text-lg text-gray-900">
{Math.round(assessmentResult.scores.prosody)}
</div>
</div>
</div>
{/* 語音識別結果 */}
{assessmentResult.transcribedText && (
<div className="bg-white rounded border p-3 mb-4">
<div className="text-xs text-gray-600 mb-2">AI </div>
<div className="font-mono text-sm text-gray-900">
{assessmentResult.transcribedText}
</div>
</div>
)}
{/* 改善建議 */}
{assessmentResult.feedback && assessmentResult.feedback.length > 0 && (
<div className="space-y-2 mb-4">
<div className="text-sm font-medium text-green-800"></div>
{assessmentResult.feedback.map((feedback, index) => (
<div key={index} className="text-sm text-green-700 flex items-start gap-2">
<span className="text-green-600 mt-0.5"></span>
<span>{feedback}</span>
</div>
))}
</div>
)}
{/* 操作按鈕 */}
<div className="flex gap-3 justify-center pt-4 border-t">
<button
onClick={handleRetry}
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors"
>
</button>
<button
onClick={handleSubmitResult}
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium"
>
</button>
</div>
</div>
)}
{/* 跳過按鈕 */}
{!hasAnswered && !isEvaluating && (
<div className="text-center">
<button
onClick={handleSkip}
className="px-6 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors"
>
</button>
</div>
)}
</div>
</div>
)
}