dramaling-vocab-learning/frontend/app/generate/page.tsx

499 lines
21 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, useMemo, useCallback, useEffect } from 'react'
import { ProtectedRoute } from '@/components/shared/ProtectedRoute'
import { Navigation } from '@/components/shared/Navigation'
import { WordPopup } from '@/components/word/WordPopup'
import { BluePlayButton } from '@/components/shared/BluePlayButton'
import { useToast } from '@/components/shared/Toast'
import { flashcardsService } from '@/lib/services/flashcards'
import { getLevelIndex } from '@/lib/utils/cefrUtils'
import { useWordAnalysis } from '@/hooks/word/useWordAnalysis'
import { API_CONFIG } from '@/lib/config/api'
import Link from 'next/link'
const MAX_MANUAL_INPUT_LENGTH = 300
interface GrammarCorrection {
hasErrors: boolean;
originalText: string;
correctedText: string | null;
corrections: Array<{
position: { start: number; end: number };
error: string;
correction: string;
type: string;
explanation: string;
severity: 'high' | 'medium' | 'low';
}>;
confidenceScore: number;
}
function GenerateContent() {
const toast = useToast()
const { findWordAnalysis, getWordClass } = useWordAnalysis()
const [textInput, setTextInput] = useState('')
const userLevel = typeof window !== 'undefined'
? localStorage.getItem('userEnglishLevel') || 'A2'
: 'A2'
const [isAnalyzing, setIsAnalyzing] = useState(false)
const [sentenceAnalysis, setSentenceAnalysis] = useState<Record<string, any> | null>(null)
const [sentenceMeaning, setSentenceMeaning] = useState('')
const [grammarCorrection, setGrammarCorrection] = useState<GrammarCorrection | null>(null)
const [selectedIdiom, setSelectedIdiom] = useState<string | null>(null)
const [selectedWord, setSelectedWord] = useState<string | null>(null)
// UX 改善:追蹤分析狀態,避免輸入和結果不匹配
const [lastAnalyzedText, setLastAnalyzedText] = useState('')
// localStorage 快取函數
const saveAnalysisToCache = useCallback((cacheData: any) => {
try {
localStorage.setItem('generate_analysis_cache', JSON.stringify(cacheData))
} catch (error) {
console.warn('無法保存分析快取:', error)
}
}, [])
const loadAnalysisFromCache = useCallback(() => {
try {
const cached = localStorage.getItem('generate_analysis_cache')
return cached ? JSON.parse(cached) : null
} catch (error) {
console.warn('無法載入分析快取:', error)
return null
}
}, [])
const clearAnalysisCache = useCallback(() => {
try {
localStorage.removeItem('generate_analysis_cache')
} catch (error) {
console.warn('無法清除分析快取:', error)
}
}, [])
// 組件載入時恢復快取的分析結果
useEffect(() => {
const cached = loadAnalysisFromCache()
if (cached) {
setTextInput(cached.textInput || '')
setLastAnalyzedText(cached.textInput || '') // 同步記錄上次分析的文本
setSentenceAnalysis(cached.sentenceAnalysis || null)
setSentenceMeaning(cached.sentenceMeaning || '')
setGrammarCorrection(cached.grammarCorrection || null)
console.log('✅ 已恢復快取的分析結果')
}
}, [loadAnalysisFromCache])
// 處理句子分析
const handleAnalyzeSentence = async () => {
// 清除舊的分析快取
clearAnalysisCache()
setIsAnalyzing(true)
try {
// 獲取認證 token
const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null
const response = await fetch(`${API_CONFIG.BASE_URL}/api/ai/analyze-sentence`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token && { 'Authorization': `Bearer ${token}` })
},
body: JSON.stringify({
inputText: textInput,
analysisMode: 'full',
options: {
includeGrammarCheck: true,
includeVocabularyAnalysis: true,
includeTranslation: true,
includeIdiomDetection: true,
includeExamples: true
}
})
})
if (!response.ok) {
if (response.status === 401) {
throw new Error('請先登入後再使用 AI 分析功能')
}
throw new Error(`API請求失敗: ${response.status}`)
}
const result = await response.json()
if (!result.success || !result.data) {
throw new Error('API回應格式錯誤')
}
const apiData = result.data.data
const analysisData = {
originalText: apiData.originalText,
sentenceMeaning: apiData.sentenceMeaning,
grammarCorrection: apiData.grammarCorrection,
vocabularyAnalysis: apiData.vocabularyAnalysis,
idioms: apiData.idioms || [],
processingTime: result.processingTime
}
setSentenceAnalysis(analysisData)
setSentenceMeaning(apiData.sentenceMeaning || '')
if (apiData.grammarCorrection) {
setGrammarCorrection({
hasErrors: apiData.grammarCorrection.hasErrors,
originalText: textInput,
correctedText: apiData.grammarCorrection.correctedText || textInput,
corrections: apiData.grammarCorrection.corrections || [],
confidenceScore: apiData.grammarCorrection.confidenceScore || 0.9
})
}
// 保存分析結果到快取
const cacheData = {
textInput,
sentenceAnalysis: analysisData,
sentenceMeaning: apiData.sentenceMeaning || '',
grammarCorrection: apiData.grammarCorrection ? {
hasErrors: apiData.grammarCorrection.hasErrors,
originalText: textInput,
correctedText: apiData.grammarCorrection.correctedText || textInput,
corrections: apiData.grammarCorrection.corrections || [],
confidenceScore: apiData.grammarCorrection.confidenceScore || 1.0
} : null
}
saveAnalysisToCache(cacheData)
// 記錄此次分析的文本
setLastAnalyzedText(textInput)
} catch (error) {
console.error('分析錯誤:', error)
toast.error('分析過程中發生錯誤,請稍後再試。')
} finally {
setIsAnalyzing(false)
}
}
// 語法修正處理
const handleAcceptCorrection = useCallback(() => {
if (grammarCorrection?.correctedText) {
setTextInput(grammarCorrection.correctedText)
setGrammarCorrection(null)
}
}, [grammarCorrection?.correctedText])
const handleRejectCorrection = useCallback(() => {
setGrammarCorrection(null)
}, [])
// 詞彙統計
const vocabularyStats = useMemo(() => {
if (!sentenceAnalysis?.vocabularyAnalysis) {
return { simpleCount: 0, moderateCount: 0, difficultCount: 0, idiomCount: 0 }
}
let simpleCount = 0, moderateCount = 0, difficultCount = 0
Object.values(sentenceAnalysis.vocabularyAnalysis).forEach((wordData: any) => {
const cefr = wordData?.cefr || 'A1'
const userIndex = getLevelIndex(userLevel)
const wordIndex = getLevelIndex(cefr)
if (userIndex > wordIndex) {
simpleCount++
} else if (userIndex === wordIndex) {
moderateCount++
} else {
difficultCount++
}
})
return {
simpleCount,
moderateCount,
difficultCount,
idiomCount: sentenceAnalysis.idioms?.length || 0
}
}, [sentenceAnalysis, userLevel])
// 保存詞彙
const handleSaveWord = useCallback(async (word: string, analysis: any) => {
try {
const cardData = {
word: word,
translation: analysis.translation || '',
definition: analysis.definition || '',
pronunciation: analysis.pronunciation || `/${word}/`,
partOfSpeech: analysis.partOfSpeech || 'noun',
example: analysis.example || `Example sentence with ${word}.`,
exampleTranslation: analysis.exampleTranslation,
synonyms: analysis.synonyms ? JSON.stringify(analysis.synonyms) : undefined,
cefr: analysis.cefr || 'A0'
}
const response = await flashcardsService.createFlashcard(cardData)
if (response.success) {
toast.success(`已成功將「${word}」保存到詞卡庫!`)
return { success: true }
} else {
throw new Error(response.error || '保存失敗')
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '保存失敗'
toast.error(`保存詞卡失敗: ${errorMessage}`)
return { success: false, error: errorMessage }
}
}, [toast])
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 sm:px-6 lg:px-8 py-8">
{/* 頁面標題和程度指示器 */}
<div className="flex justify-between items-center mb-8">
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900">AI </h1>
<Link
href="/profile"
className="flex items-center gap-2 px-4 py-2 rounded-lg transition-colors hover:shadow-md bg-gray-100 text-gray-700 border border-gray-200"
>
<span className="text-sm font-medium"> {userLevel}</span>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth="2">
<path d="M12 15a3 3 0 100-6 3 3 0 000 6z"/>
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/>
</svg>
</Link>
</div>
{/* 輸入區域 */}
<div className="bg-white rounded-xl shadow-sm p-6 mb-8">
<h2 className="text-lg font-semibold text-gray-900 mb-4"></h2>
<textarea
value={textInput}
onChange={(e) => {
const value = e.target.value
if (value.length > MAX_MANUAL_INPUT_LENGTH) return
setTextInput(value)
}}
placeholder={`輸入英文句子(最多${MAX_MANUAL_INPUT_LENGTH}字)...`}
className={`w-full h-32 px-4 py-3 text-base border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none resize-none ${
textInput.length >= MAX_MANUAL_INPUT_LENGTH - 20 ? 'border-yellow-400' :
textInput.length >= MAX_MANUAL_INPUT_LENGTH ? 'border-red-400' : 'border-gray-300'
}`}
/>
<div className="mt-2 text-sm text-gray-600">
{textInput.length}/{MAX_MANUAL_INPUT_LENGTH}
</div>
{/* 分析按鈕 */}
<button
onClick={handleAnalyzeSentence}
disabled={isAnalyzing || !textInput || textInput.length > MAX_MANUAL_INPUT_LENGTH}
className="w-full mt-4 bg-blue-600 text-white py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isAnalyzing ? (
<span className="flex items-center justify-center">
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
... ( 3-5 )
</span>
) : (
'🔍 分析句子'
)}
</button>
</div>
{/* 當有文本但無分析結果時顯示提示 */}
{!sentenceAnalysis && textInput.trim() && !isAnalyzing && (
<div className="bg-blue-50 border border-blue-200 rounded-xl p-6 text-center">
<div className="text-blue-600 mb-2">💡</div>
<p className="text-blue-800 font-medium"></p>
<p className="text-blue-600 text-sm mt-1"></p>
</div>
)}
{/* 分析結果區域 */}
{sentenceAnalysis && (
<div className="space-y-6">
{/* 語法修正面板 */}
{grammarCorrection && grammarCorrection.hasErrors && (
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-6">
<h3 className="text-lg font-semibold text-yellow-800 mb-3">🔧 </h3>
<div className="grid md:grid-cols-2 gap-4 mb-4">
<div>
<span className="text-sm font-medium text-yellow-700"></span>
<div className="bg-white p-3 rounded border border-yellow-300 mt-1 text-sm">
{textInput}
</div>
</div>
<div>
<span className="text-sm font-medium text-yellow-700"></span>
<div className="bg-yellow-100 p-3 rounded border border-yellow-300 mt-1 font-medium text-sm">
{grammarCorrection.correctedText || textInput}
</div>
</div>
</div>
<div className="flex gap-3">
<button
onClick={handleAcceptCorrection}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm"
>
</button>
<button
onClick={handleRejectCorrection}
className="px-4 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors text-sm"
>
📝
</button>
</div>
</div>
)}
{/* 詞彙分析卡片 (包含統計和分析結果) */}
<div className="bg-white rounded-xl shadow-sm p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-6"></h3>
{/* 詞彙統計 */}
<div className="mb-6">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-gray-600 mb-1">{vocabularyStats.simpleCount}</div>
<div className="text-gray-600 text-sm font-medium"></div>
</div>
<div className="bg-green-50 border border-green-200 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-green-700 mb-1">{vocabularyStats.moderateCount}</div>
<div className="text-green-700 text-sm font-medium"></div>
</div>
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-orange-700 mb-1">{vocabularyStats.difficultCount}</div>
<div className="text-orange-700 text-sm font-medium"></div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-blue-700 mb-1">{vocabularyStats.idiomCount}</div>
<div className="text-blue-700 text-sm font-medium"></div>
</div>
</div>
</div>
{/* 互動句子 */}
<div className="border rounded-lg p-6 mb-6 bg-gradient-to-r from-gray-50 to-blue-50 relative">
<div className="flex items-start gap-4 mb-4">
<div className="flex-1 text-xl leading-relaxed">
{lastAnalyzedText.split(/(\s+)/).map((token, index) => {
const cleanToken = token.replace(/[^\w']/g, '')
if (!cleanToken || /^\s+$/.test(token)) {
return <span key={index} className="whitespace-pre">{token}</span>
}
const analysis = sentenceAnalysis?.vocabularyAnalysis || {}
const wordAnalysis = findWordAnalysis(cleanToken, analysis)
if (!wordAnalysis) {
return <span key={index} className="text-gray-900">{token}</span>
}
return (
<span key={index}>
<span
className={getWordClass(cleanToken, analysis, userLevel)}
onClick={() => setSelectedWord(cleanToken)}
role="button"
tabIndex={0}
>
{token}
</span>
</span>
)
})}
</div>
<div className="flex-shrink-0 mt-1">
<BluePlayButton
text={lastAnalyzedText}
lang="en-US"
size="md"
title="點擊播放整個句子"
/>
</div>
</div>
<div className="absolute bottom-2 text-xs text-gray-500">
</div>
</div>
{/* 翻譯 */}
<div className="bg-gray-50 rounded-lg p-4 mb-4">
<h4 className="font-semibold text-gray-900 mb-2 text-sm"></h4>
<p className="text-gray-700">{sentenceMeaning}</p>
</div>
{/* 慣用語 */}
{sentenceAnalysis?.idioms && sentenceAnalysis.idioms.length > 0 && (
<div className="bg-purple-50 rounded-lg p-4">
<h4 className="font-semibold text-purple-900 mb-3 text-sm"></h4>
<div className="flex flex-wrap gap-2">
{sentenceAnalysis.idioms.map((idiom: any, index: number) => (
<span
key={index}
className="cursor-pointer px-3 py-1 bg-purple-100 border border-purple-200 rounded-full text-purple-700 text-sm font-medium hover:bg-purple-200 transition-colors"
onClick={() => setSelectedIdiom(idiom.idiom)}
title={`點擊查看「${idiom.idiom}」的詳細資訊`}
>
{idiom.idiom}
</span>
))}
</div>
</div>
)}
</div>
{/* 保存提醒 - 移到最下面 */}
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4 rounded">
<div className="flex items-center">
<div className="text-yellow-600 mr-3"></div>
<div>
<p className="text-yellow-800 font-medium"></p>
<p className="text-yellow-700 text-sm mt-1"></p>
</div>
</div>
</div>
</div>
)}
{/* 彈窗組件 */}
<WordPopup
selectedWord={selectedIdiom}
analysis={selectedIdiom ? { [selectedIdiom]: sentenceAnalysis?.idioms?.find((i: any) => i.idiom === selectedIdiom) } : {}}
isOpen={!!selectedIdiom}
onClose={() => setSelectedIdiom(null)}
onSaveWord={handleSaveWord}
/>
<WordPopup
selectedWord={selectedWord}
analysis={sentenceAnalysis?.vocabularyAnalysis || {}}
isOpen={!!selectedWord}
onClose={() => setSelectedWord(null)}
onSaveWord={handleSaveWord}
/>
<toast.ToastContainer />
</div>
</div>
)
}
export default function GeneratePage() {
return (
<ProtectedRoute>
<GenerateContent />
</ProtectedRoute>
)
}