516 lines
20 KiB
TypeScript
516 lines
20 KiB
TypeScript
'use client'
|
||
|
||
import { useState, useEffect } from 'react'
|
||
import { createPortal } from 'react-dom'
|
||
|
||
/**
|
||
* ClickableTextV2 組件架構說明
|
||
*
|
||
* 🎯 **Portal 設計解決方案**:
|
||
* - 使用 React Portal 將彈窗渲染到 document.body
|
||
* - 完全脫離父級 CSS 繼承,避免樣式污染
|
||
* - 確保彈窗樣式與 vocab-designs 頁面的詞卡風格完全一致
|
||
*
|
||
* 🏗️ **組件架構**:
|
||
* ```
|
||
* ClickableTextV2
|
||
* ├─ 文字容器 (text-lg) - 可點擊文字
|
||
* └─ VocabPopup (Portal) - 彈窗渲染到 body,不受父級影響
|
||
* ```
|
||
*
|
||
* ✅ **解決的問題**:
|
||
* - CSS 繼承問題:Portal 完全脫離父級樣式
|
||
* - 字體大小問題:彈窗使用標準字體大小
|
||
* - 對齊問題:彈窗內容正確左對齊
|
||
*
|
||
* 🔧 **技術要點**:
|
||
* - createPortal() 渲染到 document.body
|
||
* - mounted state 確保只在客戶端渲染
|
||
* - 保持原有 API 和功能不變
|
||
*/
|
||
|
||
// 更新的詞彙分析介面
|
||
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 // 新詞彙分析資料回調
|
||
onSaveWord?: (word: string, analysis: WordAnalysis) => Promise<void> // 保存詞彙回調
|
||
remainingUsage?: number // 剩餘使用次數
|
||
}
|
||
|
||
export function ClickableTextV2({
|
||
text,
|
||
analysis,
|
||
onWordClick,
|
||
onWordCostConfirm,
|
||
onNewWordAnalysis,
|
||
onSaveWord,
|
||
remainingUsage = 5
|
||
}: ClickableTextProps) {
|
||
const [selectedWord, setSelectedWord] = useState<string | null>(null)
|
||
const [popupPosition, setPopupPosition] = useState({ x: 0, y: 0, showBelow: false })
|
||
const [showCostConfirm, setShowCostConfirm] = useState<{
|
||
word: string
|
||
cost: number
|
||
position: { x: number, y: number }
|
||
} | null>(null)
|
||
const [isSavingWord, setIsSavingWord] = useState(false)
|
||
const [mounted, setMounted] = useState(false)
|
||
|
||
// 確保只在客戶端渲染 Portal
|
||
useEffect(() => {
|
||
setMounted(true)
|
||
}, [])
|
||
|
||
// 獲取CEFR等級顏色 - 與詞卡風格完全一致
|
||
const getCEFRColor = (level: string) => {
|
||
switch (level) {
|
||
case 'A1': return 'bg-green-100 text-green-700 border-green-200'
|
||
case 'A2': return 'bg-blue-100 text-blue-700 border-blue-200'
|
||
case 'B1': return 'bg-yellow-100 text-yellow-700 border-yellow-200'
|
||
case 'B2': return 'bg-orange-100 text-orange-700 border-orange-200'
|
||
case 'C1': return 'bg-red-100 text-red-700 border-red-200'
|
||
case 'C2': return 'bg-purple-100 text-purple-700 border-purple-200'
|
||
default: return 'bg-gray-100 text-gray-700 border-gray-200'
|
||
}
|
||
}
|
||
|
||
// 輔助函數:兼容大小寫屬性名稱
|
||
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 viewportWidth = window.innerWidth
|
||
const viewportHeight = window.innerHeight
|
||
const popupWidth = 320 // popup寬度 w-80 = 320px
|
||
const popupHeight = 400 // 估計popup高度
|
||
|
||
// 智能水平定位,適應不同屏幕尺寸
|
||
let x = rect.left + rect.width / 2
|
||
const actualPopupWidth = Math.min(popupWidth, viewportWidth - 32) // 實際popup寬度
|
||
const halfPopupWidth = actualPopupWidth / 2
|
||
const padding = 16 // 最小邊距
|
||
|
||
// 手機端特殊處理
|
||
if (viewportWidth <= 640) { // sm斷點
|
||
// 小屏幕時居中顯示,避免邊緣問題
|
||
x = viewportWidth / 2
|
||
} else {
|
||
// 大屏幕時智能調整位置
|
||
if (x + halfPopupWidth + padding > viewportWidth) {
|
||
x = viewportWidth - halfPopupWidth - padding
|
||
}
|
||
if (x - halfPopupWidth < padding) {
|
||
x = halfPopupWidth + padding
|
||
}
|
||
}
|
||
|
||
// 計算垂直位置
|
||
const spaceAbove = rect.top
|
||
const showBelow = spaceAbove < popupHeight
|
||
|
||
const position = {
|
||
x: x,
|
||
y: showBelow ? rect.bottom + 10 : rect.top - 10,
|
||
showBelow: showBelow
|
||
}
|
||
|
||
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) {
|
||
setPopupPosition({...showCostConfirm.position, showBelow: false})
|
||
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, showBelow: false})
|
||
setSelectedWord(showCostConfirm.word)
|
||
onWordClick?.(showCostConfirm.word, wordAnalysis)
|
||
}
|
||
}
|
||
}
|
||
|
||
setShowCostConfirm(null)
|
||
}
|
||
|
||
const closePopup = () => {
|
||
setSelectedWord(null)
|
||
}
|
||
|
||
const handleSaveWord = async () => {
|
||
if (!selectedWord || !analysis?.[selectedWord] || !onSaveWord) return
|
||
|
||
setIsSavingWord(true)
|
||
try {
|
||
await onSaveWord(selectedWord, analysis[selectedWord])
|
||
setSelectedWord(null) // 保存成功後關閉popup
|
||
} catch (error) {
|
||
console.error('Save word error:', error)
|
||
alert(`保存詞彙失敗: ${error instanceof Error ? error.message : '未知錯誤'}`)
|
||
} finally {
|
||
setIsSavingWord(false)
|
||
}
|
||
}
|
||
|
||
const queryWordWithAI = async (word: string, position: { x: number, y: number, showBelow: boolean }) => {
|
||
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`
|
||
}
|
||
}
|
||
|
||
// 詞彙彈窗組件 - 使用 Portal 渲染
|
||
const VocabPopup = () => {
|
||
if (!selectedWord || !analysis?.[selectedWord] || !mounted) return null
|
||
|
||
return createPortal(
|
||
<>
|
||
{/* 背景遮罩 */}
|
||
<div
|
||
className="fixed inset-0 bg-black bg-opacity-50 z-40"
|
||
onClick={closePopup}
|
||
/>
|
||
|
||
{/* 彈窗內容 - 完全脫離父級樣式 */}
|
||
<div
|
||
className="fixed z-50 bg-white rounded-xl shadow-lg w-96 max-w-md overflow-hidden"
|
||
style={{
|
||
left: `${popupPosition.x}px`,
|
||
top: `${popupPosition.y}px`,
|
||
transform: popupPosition.showBelow
|
||
? 'translate(-50%, 8px)'
|
||
: 'translate(-50%, calc(-100% - 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={closePopup}
|
||
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">{getWordProperty(analysis[selectedWord], 'word')}</h3>
|
||
</div>
|
||
|
||
{/* 詞性、發音、播放按鈕、CEFR */}
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-sm bg-gray-100 text-gray-700 px-3 py-1 rounded-full">
|
||
{getWordProperty(analysis[selectedWord], 'partOfSpeech')}
|
||
</span>
|
||
<span className="text-base text-gray-600">{getWordProperty(analysis[selectedWord], 'pronunciation')}</span>
|
||
<button className="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center text-white hover:bg-blue-700 transition-colors">
|
||
<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>
|
||
|
||
{/* CEFR標籤 - 在播放按鈕那一行的最右邊 */}
|
||
<span className={`px-3 py-1 rounded-full text-sm font-medium border ${getCEFRColor(getWordProperty(analysis[selectedWord], 'difficultyLevel'))}`}>
|
||
{getWordProperty(analysis[selectedWord], '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">{getWordProperty(analysis[selectedWord], '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">{getWordProperty(analysis[selectedWord], 'definition')}</p>
|
||
</div>
|
||
|
||
{/* 同義詞區塊 - 紫色 */}
|
||
{getWordProperty(analysis[selectedWord], '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-1">
|
||
{getWordProperty(analysis[selectedWord], 'synonyms')?.slice(0, 4).map((synonym: string, idx: number) => (
|
||
<span key={idx} className="bg-white text-purple-700 px-2 py-1 rounded-full text-xs border border-purple-200 font-medium">
|
||
{synonym}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 保存按鈕 - 底部平均延展 */}
|
||
{onSaveWord && (
|
||
<div className="p-4 pt-2">
|
||
<button
|
||
onClick={handleSaveWord}
|
||
disabled={isSavingWord}
|
||
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"
|
||
>
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||
</svg>
|
||
<span className="font-medium">{isSavingWord ? '保存中...' : '保存到詞卡'}</span>
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>,
|
||
document.body
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="relative">
|
||
{/* 文字內容 */}
|
||
<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
|
||
|
||
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>
|
||
|
||
{/* 使用 Portal 渲染的詞彙彈窗 */}
|
||
<VocabPopup />
|
||
|
||
{/* 收費確認對話框 - 保留原有功能 */}
|
||
{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>
|
||
)
|
||
} |