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

654 lines
28 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 } from 'react'
import { ProtectedRoute } from '@/components/ProtectedRoute'
import { Navigation } from '@/components/Navigation'
import { ClickableTextV2 } from '@/components/ClickableTextV2'
import { useToast } from '@/components/Toast'
import { flashcardsService } from '@/lib/services/flashcards'
import { Play } from 'lucide-react'
import Link from 'next/link'
// 常數定義
const CEFR_LEVELS = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2'] as const
const MAX_MANUAL_INPUT_LENGTH = 300
// 工具函數
const getLevelIndex = (level: string): number => {
return CEFR_LEVELS.indexOf(level as typeof CEFR_LEVELS[number])
}
const getTargetLearningRange = (userLevel: string): string => {
const ranges: Record<string, string> = {
'A1': 'A2-B1', 'A2': 'B1-B2', 'B1': 'B2-C1',
'B2': 'C1-C2', 'C1': 'C2', 'C2': 'C2'
}
return ranges[userLevel] || 'B1-B2'
}
const compareCEFRLevels = (level1: string, level2: string, operator: '>' | '<' | '==='): boolean => {
const levels = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2']
const index1 = levels.indexOf(level1)
const index2 = levels.indexOf(level2)
if (index1 === -1 || index2 === -1) return false
switch (operator) {
case '>': return index1 > index2
case '<': return index1 < index2
case '===': return index1 === index2
default: return false
}
}
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;
}
interface IdiomPopup {
idiom: string;
analysis: any;
position: { x: number; y: number };
}
function GenerateContent() {
const toast = useToast()
const [textInput, setTextInput] = useState('')
const [isAnalyzing, setIsAnalyzing] = useState(false)
const [showAnalysisView, setShowAnalysisView] = useState(false)
const [sentenceAnalysis, setSentenceAnalysis] = useState<Record<string, any> | null>(null)
const [sentenceMeaning, setSentenceMeaning] = useState('')
const [grammarCorrection, setGrammarCorrection] = useState<GrammarCorrection | null>(null)
const [idiomPopup, setIdiomPopup] = useState<IdiomPopup | null>(null)
// 處理句子分析 - 使用真實API
const handleAnalyzeSentence = async () => {
console.log('🚀 handleAnalyzeSentence 被調用 (真實API模式)')
setIsAnalyzing(true)
try {
const response = await fetch('http://localhost:5008/api/ai/analyze-sentence', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
inputText: textInput,
analysisMode: 'full',
options: {
includeGrammarCheck: true,
includeVocabularyAnalysis: true,
includeTranslation: true,
includeIdiomDetection: true,
includeExamples: true
}
})
})
if (!response.ok) {
let errorMessage = `API請求失敗: ${response.status}`
try {
const errorData = await response.json()
errorMessage = errorData.error?.message || errorData.message || errorMessage
} catch (e) {
console.warn('無法解析錯誤回應:', e)
}
throw new Error(errorMessage)
}
const result = await response.json()
if (!result.success || !result.data) {
throw new Error('API回應格式錯誤')
}
// 處理API回應 - 適配新的後端格式
const apiData = result.data
// 設定完整的分析結果包含vocabularyAnalysis和其他數據
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
})
} else {
setGrammarCorrection({
hasErrors: false,
originalText: textInput,
correctedText: textInput,
corrections: [],
confidenceScore: 1.0
})
}
setShowAnalysisView(true)
console.log('✅ API分析完成', apiData)
} catch (error) {
console.error('Error in sentence analysis:', error)
setGrammarCorrection({
hasErrors: true,
originalText: textInput,
correctedText: textInput,
corrections: [],
confidenceScore: 0.0
})
setSentenceMeaning('分析過程中發生錯誤,請稍後再試。')
// 錯誤時也不設置finalText使用原始輸入
setShowAnalysisView(true)
} finally {
setIsAnalyzing(false)
}
}
const handleAcceptCorrection = useCallback(() => {
if (grammarCorrection?.correctedText) {
// 更新用戶輸入為修正後的版本
setTextInput(grammarCorrection.correctedText)
console.log('✅ 已採用修正版本,文本已更新為正確版本!')
}
}, [grammarCorrection?.correctedText])
const handleRejectCorrection = useCallback(() => {
// 保持原始輸入不變,只是隱藏語法修正面板
setGrammarCorrection(null)
console.log('📝 已保持原始版本,繼續使用您的原始輸入。')
}, [])
// 詞彙統計計算 - 適配新的後端API格式
const vocabularyStats = useMemo(() => {
if (!sentenceAnalysis?.vocabularyAnalysis) {
return { simpleCount: 0, moderateCount: 0, difficultCount: 0, idiomCount: 0 }
}
const userLevel = localStorage.getItem('userEnglishLevel') || 'A2'
let simpleCount = 0
let moderateCount = 0
let difficultCount = 0
// 處理vocabularyAnalysis物件
Object.values(sentenceAnalysis.vocabularyAnalysis).forEach((wordData: any) => {
const difficultyLevel = wordData?.difficultyLevel || 'A1'
const userIndex = getLevelIndex(userLevel)
const wordIndex = getLevelIndex(difficultyLevel)
if (userIndex > wordIndex) {
simpleCount++
} else if (userIndex === wordIndex) {
moderateCount++
} else {
difficultCount++
}
})
// 處理慣用語統計
const idiomCount = sentenceAnalysis.idioms?.length || 0
return { simpleCount, moderateCount, difficultCount, idiomCount }
}, [sentenceAnalysis])
// 保存單個詞彙
const handleSaveWord = useCallback(async (word: string, analysis: any) => {
try {
const cardData = {
word: word,
translation: analysis.translation || analysis.Translation || '',
definition: analysis.definition || analysis.Definition || '',
pronunciation: analysis.pronunciation || analysis.Pronunciation || `/${word}/`,
partOfSpeech: analysis.partOfSpeech || analysis.PartOfSpeech || 'noun',
example: analysis.example || `Example sentence with ${word}.`, // 使用分析結果的例句
exampleTranslation: analysis.exampleTranslation,
difficultyLevel: analysis.difficultyLevel || analysis.cefrLevel || 'A2'
}
const response = await flashcardsService.createFlashcard(cardData)
if (response.success) {
// 顯示成功提示
const successMessage = `已成功將「${word}」保存到詞卡庫!`
toast.success(successMessage)
console.log('✅', successMessage)
return { success: true }
} else if (response.error && response.error.includes('已存在')) {
// 顯示重複提示
const duplicateMessage = `詞卡「${word}」已經存在於詞卡庫中`
toast.warning(duplicateMessage)
console.log('⚠️', duplicateMessage)
return { success: false, error: 'duplicate', message: duplicateMessage }
} else {
throw new Error(response.error || '保存失敗')
}
} catch (error) {
console.error('Save word error:', error)
const errorMessage = error instanceof Error ? error.message : '保存失敗'
toast.error(`保存詞卡失敗: ${errorMessage}`)
return { success: false, error: errorMessage }
}
}, [])
return (
<div className="min-h-screen bg-gray-50">
<Navigation />
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{!showAnalysisView ? (
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl font-bold mb-8">AI </h1>
{/* Content Input */}
<div className="bg-white rounded-xl shadow-sm p-4 sm:p-6 mb-6">
<h2 className="text-lg font-semibold 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 sm:h-40 px-3 sm:px-4 py-2 sm:py-3 text-sm sm:text-base border rounded-lg focus:ring-2 focus:ring-primary 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 flex justify-between text-sm">
<span className={`${
textInput.length >= MAX_MANUAL_INPUT_LENGTH - 20 ? 'text-yellow-600' :
textInput.length >= MAX_MANUAL_INPUT_LENGTH ? 'text-red-600' : 'text-gray-600'
}`}>
{MAX_MANUAL_INPUT_LENGTH} {textInput.length}
</span>
{textInput.length > MAX_MANUAL_INPUT_LENGTH - 50 && (
<span className={textInput.length >= MAX_MANUAL_INPUT_LENGTH ? 'text-red-600' : 'text-yellow-600'}>
{textInput.length >= MAX_MANUAL_INPUT_LENGTH ? '已達上限!' : `還可輸入 ${MAX_MANUAL_INPUT_LENGTH - textInput.length} 字元`}
</span>
)}
</div>
</div>
{/* Action Buttons */}
<div className="space-y-4">
{/* 句子分析按鈕 */}
<button
onClick={handleAnalyzeSentence}
disabled={isAnalyzing || !textInput || textInput.length > MAX_MANUAL_INPUT_LENGTH}
className="w-full bg-blue-600 text-white py-4 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" xmlns="http://www.w3.org/2000/svg" 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>
... (AI 3-5 )
</span>
) : (
'🔍 分析句子'
)}
</button>
{/* 個人化程度指示器 */}
<div className="text-center text-sm text-gray-600 mt-2">
{(() => {
const userLevel = localStorage.getItem('userEnglishLevel') || 'A2'
return (
<div className="flex items-center justify-center gap-2">
<span>🎯 : {userLevel}</span>
<span className="text-gray-400">|</span>
<span>📈 : {getTargetLearningRange(userLevel)}</span>
<Link
href="/settings"
className="text-blue-500 hover:text-blue-700 ml-2"
>
調
</Link>
</div>
)
})()}
</div>
</div>
</div>
) : (
/* 重新設計的句子分析視圖 - 簡潔流暢 */
<div className="max-w-4xl mx-auto">
{/* 星星標記說明 */}
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mb-6">
<div className="flex items-center gap-2 text-sm text-yellow-800">
<span className="text-yellow-500 text-base"></span>
<span className="font-medium"> </span>
</div>
</div>
{/* 移除冗餘標題,直接進入內容 */}
{/* 語法修正面板 - 如果需要的話 */}
{grammarCorrection && grammarCorrection.hasErrors && (
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-6 mb-6">
<div className="flex items-start gap-3">
<div className="text-yellow-600 text-2xl"></div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-yellow-800 mb-2"></h3>
<p className="text-yellow-700 mb-4">AI建議修正以下內容</p>
<div className="space-y-3 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">
{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">
{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"
>
</button>
<button
onClick={handleRejectCorrection}
className="px-4 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors"
>
📝
</button>
</div>
</div>
</div>
</div>
)}
{/* 主句子展示 - 最重要的內容 */}
<div className="bg-white rounded-xl shadow-lg p-8 mb-6">
{/* 詞彙統計卡片區 */}
{vocabularyStats && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 sm:gap-4 mb-6">
{/* 簡單詞彙卡片 */}
<div className="bg-gray-50 border border-dashed border-gray-300 rounded-lg p-3 sm:p-4 text-center">
<div className="text-xl sm:text-2xl font-bold text-gray-600 mb-1">{vocabularyStats.simpleCount}</div>
<div className="text-gray-600 text-sm sm:text-base font-medium"></div>
</div>
{/* 適中詞彙卡片 */}
<div className="bg-green-50 border border-green-200 rounded-lg p-3 sm:p-4 text-center">
<div className="text-xl sm:text-2xl font-bold text-green-700 mb-1">{vocabularyStats.moderateCount}</div>
<div className="text-green-700 text-sm sm:text-base font-medium"></div>
</div>
{/* 艱難詞彙卡片 */}
<div className="bg-orange-50 border border-orange-200 rounded-lg p-3 sm:p-4 text-center">
<div className="text-xl sm:text-2xl font-bold text-orange-700 mb-1">{vocabularyStats.difficultCount}</div>
<div className="text-orange-700 text-sm sm:text-base font-medium"></div>
</div>
{/* 片語與俚語卡片 */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 sm:p-4 text-center">
<div className="text-xl sm:text-2xl font-bold text-blue-700 mb-1">{vocabularyStats.idiomCount}</div>
<div className="text-blue-700 text-sm sm:text-base font-medium"></div>
</div>
</div>
)}
{/* 句子主體展示 */}
<div className="text-left mb-8">
<div className="text-xl sm:text-2xl lg:text-3xl font-medium text-gray-900 mb-6" >
<ClickableTextV2
text={textInput}
analysis={sentenceAnalysis?.vocabularyAnalysis || undefined}
showIdiomsInline={false}
onWordClick={(word, analysis) => {
console.log('Clicked word:', word, analysis)
}}
onSaveWord={handleSaveWord}
/>
</div>
{/* 翻譯 - 參考翻卡背面設計 */}
<div className="bg-gray-50 rounded-lg p-4">
<h3 className="font-semibold text-gray-900 mb-2 text-left"></h3>
<p className="text-gray-700 text-left">{sentenceMeaning}</p>
</div>
{/* 片語和慣用語展示區 */}
{(() => {
if (!sentenceAnalysis?.idioms || sentenceAnalysis.idioms.length === 0) return null
// 使用新的API格式中的idioms陣列
const idioms = sentenceAnalysis.idioms
return (
<div className="bg-gray-50 rounded-lg p-4 mt-4">
<h3 className="font-semibold text-gray-900 mb-2 text-left"></h3>
<div className="flex flex-wrap gap-2">
{idioms.map((idiom: any, index: number) => (
<span
key={index}
className="cursor-pointer transition-all duration-200 rounded-lg relative mx-0.5 px-1 py-0.5 inline-flex items-center gap-1 bg-blue-50 border border-blue-200 hover:bg-blue-100 hover:shadow-lg transform hover:-translate-y-0.5 text-blue-700 font-medium"
onClick={(e) => {
// 使用新的API格式直接使用idiom物件
setIdiomPopup({
idiom: idiom.idiom,
analysis: idiom,
position: {
x: e.currentTarget.getBoundingClientRect().left + e.currentTarget.getBoundingClientRect().width / 2,
y: e.currentTarget.getBoundingClientRect().bottom + 10
}
})
}}
title={`${idiom.idiom}: ${idiom.translation}`}
>
{idiom.idiom}
{(() => {
// 只有當慣用語為常用且不是簡單慣用語時才顯示星星
// 簡單慣用語定義學習者CEFR > 慣用語CEFR
const userLevel = localStorage.getItem('userEnglishLevel') || 'A2'
const isHighFrequency = idiom?.frequency === 'high'
const idiomCefr = idiom?.cefrLevel || 'A1'
const isNotSimpleIdiom = !compareCEFRLevels(userLevel, idiomCefr, '>')
return isHighFrequency && isNotSimpleIdiom ? (
<span
className="absolute -top-1 -right-1 text-xs pointer-events-none z-10"
style={{ fontSize: '8px', lineHeight: 1 }}
>
</span>
) : null
})()}
</span>
))}
</div>
</div>
)
})()}
</div>
</div>
{/* 下方操作區 - 簡化 */}
<div className="flex justify-center px-4">
<button
onClick={() => setShowAnalysisView(false)}
className="w-full sm:w-auto px-6 sm:px-8 py-3 bg-primary text-white rounded-lg font-medium hover:bg-primary-hover transition-colors flex items-center justify-center gap-2"
>
<span></span>
</button>
</div>
</div>
)}
{/* 片語彈窗 */}
{idiomPopup && (
<>
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40"
onClick={() => setIdiomPopup(null)}
/>
<div
className="fixed z-50 bg-white rounded-xl shadow-lg w-96 max-w-md overflow-hidden"
style={{
left: `${idiomPopup.position.x}px`,
top: `${idiomPopup.position.y}px`,
transform: 'translate(-50%, 8px)',
maxHeight: '85vh',
overflowY: 'auto'
}}
>
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 p-5 border-b border-blue-200">
<div className="flex justify-end mb-3">
<button
onClick={() => setIdiomPopup(null)}
className="text-gray-400 hover:text-gray-600 w-6 h-6 rounded-full bg-white bg-opacity-80 hover:bg-opacity-100 transition-all flex items-center justify-center"
>
</button>
</div>
<div className="mb-3">
<h3 className="text-2xl font-bold text-gray-900">{idiomPopup.analysis.idiom}</h3>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="text-base text-gray-600">{idiomPopup.analysis.pronunciation}</span>
<button
onClick={() => {
const utterance = new SpeechSynthesisUtterance(idiomPopup.analysis.idiom);
utterance.lang = 'en-US';
utterance.rate = 0.8;
speechSynthesis.speak(utterance);
}}
className="flex items-center justify-center w-8 h-8 rounded-full bg-blue-600 hover:bg-blue-700 text-white transition-colors"
title="播放發音"
>
<Play size={16} />
</button>
</div>
</div>
<span className="px-3 py-1 rounded-full text-sm font-medium border bg-blue-100 text-blue-700 border-blue-200">
{idiomPopup.analysis.difficultyLevel}
</span>
</div>
</div>
<div className="p-4 space-y-4">
<div className="bg-green-50 rounded-lg p-3 border border-green-200">
<h4 className="font-semibold text-green-900 mb-2 text-left text-sm"></h4>
<p className="text-green-800 font-medium text-left">{idiomPopup.analysis.translation}</p>
</div>
<div className="bg-gray-50 rounded-lg p-3 border border-gray-200">
<h4 className="font-semibold text-gray-900 mb-2 text-left text-sm"></h4>
<p className="text-gray-700 text-left text-sm leading-relaxed">{idiomPopup.analysis.definition}</p>
</div>
{idiomPopup.analysis.example && (
<div className="bg-blue-50 rounded-lg p-3 border border-blue-200">
<h4 className="font-semibold text-blue-900 mb-2 text-left text-sm"></h4>
<div className="space-y-2">
<p className="text-blue-800 text-left text-sm italic">
"{idiomPopup.analysis.example}"
</p>
<p className="text-blue-700 text-left text-sm">
{idiomPopup.analysis.exampleTranslation}
</p>
</div>
</div>
)}
{idiomPopup.analysis.synonyms && Array.isArray(idiomPopup.analysis.synonyms) && idiomPopup.analysis.synonyms.length > 0 && (
<div className="bg-purple-50 rounded-lg p-3 border border-purple-200">
<h4 className="font-semibold text-purple-900 mb-2 text-left text-sm"></h4>
<div className="flex flex-wrap gap-2">
{idiomPopup.analysis.synonyms.map((synonym: string, index: number) => (
<span
key={index}
className="bg-purple-100 text-purple-700 px-2 py-1 rounded-full text-xs font-medium"
>
{synonym}
</span>
))}
</div>
</div>
)}
</div>
<div className="p-4 pt-2">
<button
onClick={async () => {
const result = await handleSaveWord(idiomPopup.idiom, idiomPopup.analysis)
if (result.success) {
setIdiomPopup(null)
} else {
console.error('Save idiom error:', result.error)
}
}}
className="w-full bg-primary text-white py-3 rounded-lg font-medium hover:bg-primary-hover transition-colors flex items-center justify-center gap-2"
>
<span className="font-medium"></span>
</button>
</div>
</div>
</>
)}
{/* Toast 通知系統 */}
<toast.ToastContainer />
</div>
</div>
)
}
export default function GeneratePage() {
return (
<ProtectedRoute>
<GenerateContent />
</ProtectedRoute>
)
}