dramaling-vocab-learning/frontend/components/ClickableTextV2.tsx

474 lines
18 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 } from 'react'
// 更新的詞彙分析介面
interface WordAnalysis {
word: string
translation: string
definition: string
partOfSpeech: string
pronunciation: string
synonyms: string[]
antonyms?: string[]
isPhrase: boolean
isHighValue: boolean // 高學習價值標記
learningPriority: 'high' | 'medium' | 'low' // 學習優先級
phraseInfo?: {
phrase: string
meaning: string
warning: string
colorCode: string // 片語顏色代碼
}
difficultyLevel: string
costIncurred?: number // 點擊此詞彙的成本
}
interface ClickableTextProps {
text: string
analysis?: Record<string, WordAnalysis>
highValueWords?: string[] // 高價值詞彙列表
phrasesDetected?: Array<{
phrase: string
words: string[]
colorCode: string
}>
onWordClick?: (word: string, analysis: WordAnalysis) => void
onWordCostConfirm?: (word: string, cost: number) => Promise<boolean> // 收費確認
onNewWordAnalysis?: (word: string, analysis: WordAnalysis) => void // 新詞彙分析資料回調
remainingUsage?: number // 剩餘使用次數
}
export function ClickableTextV2({
text,
analysis,
highValueWords = [],
phrasesDetected = [],
onWordClick,
onWordCostConfirm,
onNewWordAnalysis,
remainingUsage = 5
}: ClickableTextProps) {
const [selectedWord, setSelectedWord] = useState<string | null>(null)
const [popupPosition, setPopupPosition] = useState({ x: 0, y: 0 })
const [showCostConfirm, setShowCostConfirm] = useState<{
word: string
cost: number
position: { x: number, y: number }
} | null>(null)
// 輔助函數:兼容大小寫屬性名稱
const getWordProperty = (wordData: any, propName: string) => {
const lowerProp = propName.toLowerCase()
const upperProp = propName.charAt(0).toUpperCase() + propName.slice(1)
return wordData?.[lowerProp] || wordData?.[upperProp]
}
// 將文字分割成單字,保留空格
const words = text.split(/(\s+|[.,!?;:])/g)
const handleWordClick = async (word: string, event: React.MouseEvent) => {
const cleanWord = word.toLowerCase().replace(/[.,!?;:]/g, '')
const wordAnalysis = analysis?.[cleanWord]
const rect = event.currentTarget.getBoundingClientRect()
const position = {
x: rect.left + rect.width / 2,
y: rect.top - 10
}
if (wordAnalysis) {
// 場景A有預存資料的詞彙
const isHighValue = getWordProperty(wordAnalysis, 'isHighValue')
if (isHighValue) {
// 高價值詞彙 → 直接免費顯示
setPopupPosition(position)
setSelectedWord(cleanWord)
onWordClick?.(cleanWord, wordAnalysis)
} else {
// 低價值詞彙 → 直接顯示(移除付費限制)
setPopupPosition(position)
setSelectedWord(cleanWord)
onWordClick?.(cleanWord, wordAnalysis)
}
} else {
// 場景B無預存資料的詞彙 → 即時調用 AI 查詢
await queryWordWithAI(cleanWord, position)
}
}
const handleCostConfirm = async () => {
if (!showCostConfirm) return
const confirmed = await onWordCostConfirm?.(showCostConfirm.word, showCostConfirm.cost)
if (confirmed) {
// 調用真實的單字查詢API
try {
const response = await fetch('http://localhost:5000/api/ai/query-word', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
word: showCostConfirm.word,
sentence: text,
analysisId: null // 可以傳入分析ID
})
})
if (response.ok) {
const result = await response.json()
if (result.success) {
// 更新分析資料
const newAnalysis = {
...analysis,
[showCostConfirm.word]: result.data.analysis
}
setPopupPosition(showCostConfirm.position)
setSelectedWord(showCostConfirm.word)
onWordClick?.(showCostConfirm.word, result.data.analysis)
}
}
} catch (error) {
console.error('Query word API error:', error)
// 回退到現有資料
const wordAnalysis = analysis?.[showCostConfirm.word]
if (wordAnalysis) {
setPopupPosition(showCostConfirm.position)
setSelectedWord(showCostConfirm.word)
onWordClick?.(showCostConfirm.word, wordAnalysis)
}
}
}
setShowCostConfirm(null)
}
const closePopup = () => {
setSelectedWord(null)
}
const queryWordWithAI = async (word: string, position: { x: number, y: number }) => {
try {
console.log(`🤖 查詢單字: ${word}`)
const response = await fetch('http://localhost:5000/api/ai/query-word', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
word: word,
sentence: text,
analysisId: null
})
})
if (response.ok) {
const result = await response.json()
console.log('AI 查詢結果:', result)
if (result.success && result.data?.analysis) {
// 將新的分析資料通知父組件
onNewWordAnalysis?.(word, result.data.analysis)
// 顯示分析結果
setPopupPosition(position)
setSelectedWord(word)
onWordClick?.(word, result.data.analysis)
} else {
alert(`❌ 查詢 "${word}" 失敗,請稍後再試`)
}
} else {
throw new Error(`API 錯誤: ${response.status}`)
}
} catch (error) {
console.error('AI 查詢錯誤:', error)
alert(`❌ 查詢 "${word}" 時發生錯誤,請稍後再試`)
}
}
const getWordClass = (word: string) => {
const cleanWord = word.toLowerCase().replace(/[.,!?;:]/g, '')
const wordAnalysis = analysis?.[cleanWord]
const baseClass = "cursor-pointer transition-all duration-200 rounded relative mx-0.5 px-1 py-0.5"
if (wordAnalysis) {
// 有預存資料的詞彙
const isHighValue = getWordProperty(wordAnalysis, 'isHighValue')
const isPhrase = getWordProperty(wordAnalysis, 'isPhrase')
// 高價值片語(黃色系)
if (isHighValue && isPhrase) {
return `${baseClass} bg-yellow-100 border-2 border-yellow-400 hover:bg-yellow-200 hover:shadow-sm transform hover:-translate-y-0.5`
}
// 高價值單字(綠色系)
if (isHighValue && !isPhrase) {
return `${baseClass} bg-green-100 border-2 border-green-400 hover:bg-green-200 hover:shadow-sm transform hover:-translate-y-0.5`
}
// 普通單字(藍色系)
return `${baseClass} bg-blue-100 border-2 border-blue-300 hover:bg-blue-200 hover:shadow-sm`
} else {
// 無預存資料的詞彙(灰色虛線,表示需要即時查詢)
return `${baseClass} border-2 border-dashed border-gray-300 hover:border-gray-400 bg-gray-50 hover:bg-gray-100`
}
}
return (
<div className="relative">
{/* 點擊區域遮罩 */}
{selectedWord && (
<div
className="fixed inset-0 z-10"
onClick={closePopup}
/>
)}
{/* 文字內容 */}
<div className="text-lg leading-relaxed">
{words.map((word, index) => {
// 如果是空格或標點,直接顯示
if (word.trim() === '' || /^[.,!?;:\s]+$/.test(word)) {
return <span key={index}>{word}</span>
}
const className = getWordClass(word)
const cleanWord = word.toLowerCase().replace(/[.,!?;:]/g, '')
const wordAnalysis = analysis?.[cleanWord]
const isHighValue = wordAnalysis?.isHighValue || wordAnalysis?.IsHighValue
return (
<span
key={index}
className={`${className} ${isHighValue ? 'relative' : ''}`}
onClick={(e) => handleWordClick(word, e)}
>
{word}
{isHighValue && (
<span className="absolute -top-1 -right-1 text-xs"></span>
)}
</span>
)
})}
</div>
{/* 單字資訊彈窗 */}
{selectedWord && analysis?.[selectedWord] && (
<div
className="fixed z-20 bg-white border border-gray-300 rounded-lg shadow-lg p-4 w-80 max-w-sm"
style={{
left: `${popupPosition.x}px`,
top: `${popupPosition.y}px`,
transform: 'translate(-50%, -100%)',
}}
>
<div className="space-y-3">
{/* 標題 */}
<div className="flex items-center justify-between">
<h3 className="text-lg font-bold text-gray-900">
{getWordProperty(analysis[selectedWord], 'word')}
</h3>
<button
onClick={closePopup}
className="text-gray-400 hover:text-gray-600"
>
</button>
</div>
{/* 高價值標記 */}
{getWordProperty(analysis[selectedWord], 'isHighValue') && (
<div className="bg-green-50 border border-green-200 rounded-lg p-3">
<div className="flex items-center gap-2">
<div className="text-green-600 text-lg"></div>
<div className="text-sm font-medium text-green-800">
</div>
<div className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded-full">
{getWordProperty(analysis[selectedWord], 'learningPriority') === 'high' ? '⭐⭐⭐⭐⭐' :
getWordProperty(analysis[selectedWord], 'learningPriority') === 'medium' ? '⭐⭐⭐' : '⭐'}
</div>
</div>
</div>
)}
{/* 片語警告 */}
{analysis[selectedWord].isPhrase && analysis[selectedWord].phraseInfo && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<div className="text-yellow-600 text-lg"></div>
<div>
<div className="text-sm font-medium text-yellow-800">
</div>
<div className="text-sm text-yellow-700 mt-1">
<strong></strong>{analysis[selectedWord].phraseInfo.phrase}
</div>
<div className="text-sm text-yellow-700">
<strong></strong>{analysis[selectedWord].phraseInfo.meaning}
</div>
<div className="text-xs text-yellow-600 mt-2 italic">
{analysis[selectedWord].phraseInfo.warning}
</div>
</div>
</div>
</div>
)}
{/* 詞性和發音 */}
<div className="flex items-center gap-4">
<span className="text-sm bg-gray-100 px-2 py-1 rounded">
{getWordProperty(analysis[selectedWord], 'partOfSpeech')}
</span>
<span className="text-sm text-gray-600">
{getWordProperty(analysis[selectedWord], 'pronunciation')}
</span>
<button className="text-blue-600 hover:text-blue-800">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
</svg>
</button>
</div>
{/* 翻譯 */}
<div>
<div className="text-sm font-medium text-gray-700"></div>
<div className="text-base text-gray-900">{getWordProperty(analysis[selectedWord], 'translation')}</div>
</div>
{/* 定義 */}
<div>
<div className="text-sm font-medium text-gray-700"></div>
<div className="text-sm text-gray-600">{getWordProperty(analysis[selectedWord], 'definition')}</div>
</div>
{/* 同義詞 */}
{getWordProperty(analysis[selectedWord], 'synonyms')?.length > 0 && (
<div>
<div className="text-sm font-medium text-gray-700"></div>
<div className="flex flex-wrap gap-1 mt-1">
{getWordProperty(analysis[selectedWord], 'synonyms')?.map((synonym, idx) => (
<span
key={idx}
className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full"
>
{synonym}
</span>
))}
</div>
</div>
)}
{/* 反義詞 */}
{getWordProperty(analysis[selectedWord], 'antonyms')?.length > 0 && (
<div>
<div className="text-sm font-medium text-gray-700"></div>
<div className="flex flex-wrap gap-1 mt-1">
{getWordProperty(analysis[selectedWord], 'antonyms')?.map((antonym, idx) => (
<span
key={idx}
className="text-xs bg-red-100 text-red-700 px-2 py-1 rounded-full"
>
{antonym}
</span>
))}
</div>
</div>
)}
{/* 難度等級 */}
<div>
<div className="text-sm font-medium text-gray-700"></div>
<div className="inline-flex items-center gap-1 mt-1">
<span className={`text-xs px-2 py-1 rounded-full ${
(() => {
const difficulty = getWordProperty(analysis[selectedWord], 'difficultyLevel')
return difficulty === 'A1' || difficulty === 'A2' ? 'bg-green-100 text-green-700' :
difficulty === 'B1' || difficulty === 'B2' ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
})()
}`}>
CEFR {getWordProperty(analysis[selectedWord], 'difficultyLevel')}
</span>
<span className="text-xs text-gray-500">
({(() => {
const difficulty = getWordProperty(analysis[selectedWord], 'difficultyLevel')
return difficulty === 'A1' || difficulty === 'A2' ? '基礎' :
difficulty === 'B1' || difficulty === 'B2' ? '中級' : '高級'
})()})
</span>
</div>
</div>
</div>
</div>
)}
{/* 收費確認對話框 */}
{showCostConfirm && (
<>
<div className="fixed inset-0 z-10" onClick={() => setShowCostConfirm(null)} />
<div
className="fixed z-20 bg-white border border-gray-300 rounded-lg shadow-lg p-4 w-72"
style={{
left: `${showCostConfirm.position.x}px`,
top: `${showCostConfirm.position.y}px`,
transform: 'translate(-50%, -100%)',
}}
>
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-lg font-bold text-gray-900">
{showCostConfirm.word}
</h3>
<button
onClick={() => setShowCostConfirm(null)}
className="text-gray-400 hover:text-gray-600"
>
</button>
</div>
<div className="bg-orange-50 border border-orange-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<div className="text-orange-600 text-lg">💰</div>
<div>
<div className="text-sm font-medium text-orange-800">
</div>
<div className="text-sm text-orange-700 mt-1">
<strong>{showCostConfirm.cost} </strong> 使
</div>
<div className="text-sm text-orange-600 mt-1">
<strong>{remainingUsage}</strong>
</div>
</div>
</div>
</div>
<div className="flex gap-2">
<button
onClick={handleCostConfirm}
className="flex-1 bg-blue-600 text-white py-2 px-4 rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors"
>
</button>
<button
onClick={() => setShowCostConfirm(null)}
className="flex-1 bg-gray-200 text-gray-700 py-2 px-4 rounded-lg text-sm font-medium hover:bg-gray-300 transition-colors"
>
</button>
</div>
</div>
</div>
</>
)}
</div>
)
}