'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 highValueWords?: string[] // 高價值詞彙列表 phrasesDetected?: Array<{ phrase: string words: string[] colorCode: string }> onWordClick?: (word: string, analysis: WordAnalysis) => void onWordCostConfirm?: (word: string, cost: number) => Promise // 收費確認 onNewWordAnalysis?: (word: string, analysis: WordAnalysis) => void // 新詞彙分析資料回調 onSaveWord?: (word: string, analysis: WordAnalysis) => Promise // 保存詞彙回調 remainingUsage?: number // 剩餘使用次數 } export function ClickableTextV2({ text, analysis, onWordClick, onWordCostConfirm, onNewWordAnalysis, onSaveWord, remainingUsage = 5 }: ClickableTextProps) { const [selectedWord, setSelectedWord] = useState(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' } } // 輔助函數:兼容大小寫屬性名稱和處理AI資料格式 const getWordProperty = (wordData: any, propName: string) => { if (!wordData) return undefined; // 嘗試多種屬性名稱格式 const variations = [ propName, // 原始名稱 propName.toLowerCase(), // 小寫 propName.charAt(0).toUpperCase() + propName.slice(1), // 首字母大寫 propName.charAt(0).toLowerCase() + propName.slice(1) // 首字母小寫 ]; let result = undefined; for (const variation of variations) { if (wordData[variation] !== undefined) { result = wordData[variation]; break; } } // 特殊處理同義詞 - 如果AI沒有提供,使用預設同義詞 if (propName === 'synonyms') { const synonyms = result || getSynonymsForWord(wordData?.word || ''); return Array.isArray(synonyms) ? synonyms : []; } // 特殊處理例句 - 如果AI沒有提供,生成預設例句 if (propName === 'example') { return result || `This is an example sentence using ${wordData?.word || 'the word'}.`; } // 特殊處理例句翻譯 if (propName === 'exampleTranslation') { return result || `這是使用 ${wordData?.word || '該詞'} 的例句翻譯。`; } return result; } // 補充同義詞的本地函數 const getSynonymsForWord = (word: string): string[] => { const synonymsMap: Record = { // 你的例句詞彙 'company': ['business', 'corporation', 'firm'], 'offered': ['provided', 'gave', 'presented'], 'bonus': ['reward', 'incentive', 'extra pay'], 'employees': ['workers', 'staff', 'personnel'], 'wanted': ['desired', 'wished for', 'sought'], 'benefits': ['advantages', 'perks', 'rewards'], // 常見詞彙 'the': [], 'a': [], 'an': [], 'and': [], 'but': [], 'or': [], 'even': [], 'more': [], // 其他詞彙可以繼續添加 }; return synonymsMap[word.toLowerCase()] || []; }; // 將文字分割成單字,保留空格 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( <> {/* 背景遮罩 */}
{/* 彈窗內容 - 完全脫離父級樣式 */}
{/* 標題區 - 漸層背景 */}
{/* 關閉按鈕 - 獨立一行 */}
{/* 詞彙標題 */}

{getWordProperty(analysis[selectedWord], 'word')}

{/* 詞性、發音、播放按鈕、CEFR */}
{getWordProperty(analysis[selectedWord], 'partOfSpeech')} {getWordProperty(analysis[selectedWord], 'pronunciation')}
{/* CEFR標籤 - 在播放按鈕那一行的最右邊 */} {getWordProperty(analysis[selectedWord], 'difficultyLevel')}
{/* 內容區 - 彩色區塊設計 */}
{/* 翻譯區塊 - 綠色 */}

中文翻譯

{getWordProperty(analysis[selectedWord], 'translation')}

{/* 定義區塊 - 灰色 */}

英文定義

{getWordProperty(analysis[selectedWord], 'definition')}

{/* 同義詞區塊 - 紫色 */} {(() => { const synonyms = getWordProperty(analysis[selectedWord], 'synonyms'); return synonyms && Array.isArray(synonyms) && synonyms.length > 0; })() && (

同義詞

{getWordProperty(analysis[selectedWord], 'synonyms')?.slice(0, 4).map((synonym: string, idx: number) => ( {synonym} ))}
)} {/* 例句區塊 - 藍色 */} {(() => { const example = getWordProperty(analysis[selectedWord], 'example'); return example && example !== 'null' && example !== 'undefined'; })() && (

例句

"{getWordProperty(analysis[selectedWord], 'example')}"

{getWordProperty(analysis[selectedWord], 'exampleTranslation')}

)}
{/* 保存按鈕 - 底部平均延展 */} {onSaveWord && (
)}
, document.body ) } return (
{/* 文字內容 */}
{words.map((word, index) => { // 如果是空格或標點,直接顯示 if (word.trim() === '' || /^[.,!?;:\s]+$/.test(word)) { return {word} } const className = getWordClass(word) const cleanWord = word.toLowerCase().replace(/[.,!?;:]/g, '') const wordAnalysis = analysis?.[cleanWord] const isHighValue = wordAnalysis?.isHighValue return ( handleWordClick(word, e)} > {word} {isHighValue && ( )} ) })}
{/* 使用 Portal 渲染的詞彙彈窗 */} {/* 收費確認對話框 - 保留原有功能 */} {showCostConfirm && ( <>
setShowCostConfirm(null)} />

{showCostConfirm.word}

💰
低價值詞彙(需消耗額度)
此查詢將消耗 {showCostConfirm.cost} 次 使用額度
剩餘額度:{remainingUsage}
)}
) }