294 lines
11 KiB
TypeScript
294 lines
11 KiB
TypeScript
'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>
|
||
)
|
||
} |