Spaces:
Sleeping
Sleeping
feat: Use is_api_key_error flag from backend + onclick-based button for reliable change key UX
bc7bdb7 | // static/script.js | |
| // Timestamp generator for downloaded files: YYYYMMDD_HHMM_XXXXXX | |
| function getTimestampedFileName(baseName) { | |
| const now = new Date(); | |
| const year = now.getFullYear(); | |
| const month = String(now.getMonth() + 1).padStart(2, '0'); | |
| const day = String(now.getDate()).padStart(2, '0'); | |
| const hours = String(now.getHours()).padStart(2, '0'); | |
| const minutes = String(now.getMinutes()).padStart(2, '0'); | |
| return `${year}${month}${day}_${hours}${minutes}_${baseName}`; | |
| } | |
| document.addEventListener('DOMContentLoaded', () => { | |
| // 常用單字速查快卡資料庫 | |
| const PHRASES = [ | |
| { zh: "你好", desc: "日常招呼" }, | |
| { zh: "謝謝你", desc: "表達感恩" }, | |
| { zh: "再見", desc: "告別祝願" }, | |
| { zh: "加油", desc: "鼓舞打氣" }, | |
| { zh: "早安", desc: "晨間問候" }, | |
| { zh: "晚安", desc: "夜間問候" }, | |
| { zh: "吃飽了嗎?", desc: "關心問候" }, | |
| { zh: "很高興認識你", desc: "禮貌交友" }, | |
| { zh: "請問這個多少錢?", desc: "購物問價" }, | |
| { zh: "祝你幸運成功", desc: "祝福" } | |
| ]; | |
| // 全域變數 | |
| let currentTribe = "太魯閣"; | |
| let currentMode = "zh_to_native"; // 預設:華 ➔ 族 | |
| let lastSourceText = ""; | |
| let lastTargetText = ""; | |
| let lastNativeText = ""; | |
| let lastChineseText = ""; | |
| let lastDirection = "zh_to_native"; | |
| let currentAudio = null; | |
| let mediaRecorder = null; | |
| let audioChunks = []; | |
| // --- DOM 元件獲取 --- | |
| const tribeSelect = document.getElementById('tribeSelect'); | |
| const textInput = document.getElementById('textInput'); | |
| const textOutput = document.getElementById('textOutput'); | |
| const charCount = document.getElementById('charCount'); | |
| const speedTimer = document.getElementById('speedTimer'); | |
| const translateBtn = document.getElementById('translateBtn'); | |
| const ttsBtn = document.getElementById('ttsBtn'); | |
| const deepTranslateBtn = document.getElementById('deepTranslateBtn'); | |
| const copyBtn = document.getElementById('copyBtn'); | |
| const confidenceBadge = document.getElementById('confidenceBadge'); | |
| const confidenceLevel = document.getElementById('confidenceLevel'); | |
| const confidenceDesc = document.getElementById('confidenceDesc'); | |
| const phrasesGrid = document.getElementById('phrasesGrid'); | |
| const historyList = document.getElementById('historyList'); | |
| // 1. 初始化 10 句速查快卡 | |
| function initPhrasebook() { | |
| phrasesGrid.innerHTML = ''; | |
| PHRASES.forEach(p => { | |
| const card = document.createElement('div'); | |
| card.className = 'phrase-card'; | |
| card.innerHTML = ` | |
| <div class="phrase-zh">${p.zh}</div> | |
| <div class="phrase-desc">${p.desc}</div> | |
| `; | |
| card.addEventListener('click', () => { | |
| textInput.value = p.zh; | |
| updateCharCount(); | |
| updateDirBtnsUI('zh_to_native'); | |
| handleTranslate(); | |
| }); | |
| phrasesGrid.appendChild(card); | |
| }); | |
| } | |
| // 2. Tab 切換邏輯 | |
| const tabBtns = document.querySelectorAll('.tab-btn'); | |
| const tabContents = document.querySelectorAll('.tab-content'); | |
| tabBtns.forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| tabBtns.forEach(b => b.classList.remove('active')); | |
| tabContents.forEach(c => c.classList.remove('active')); | |
| btn.classList.add('active'); | |
| const targetTab = btn.getAttribute('data-tab'); | |
| document.getElementById(targetTab).classList.add('active'); | |
| }); | |
| }); | |
| // 3. 翻譯方向模式按鈕處理 | |
| const dirBtns = document.querySelectorAll('.dir-btn'); | |
| function updateDirBtnsUI(mode) { | |
| currentMode = mode; | |
| dirBtns.forEach(btn => { | |
| if (btn.getAttribute('data-mode') === mode) { | |
| btn.classList.add('active'); | |
| } else { | |
| btn.classList.remove('active'); | |
| } | |
| }); | |
| const inputBoxTitle = document.getElementById('inputBoxTitle'); | |
| if (inputBoxTitle) { | |
| if (mode === 'zh_to_native') { | |
| inputBoxTitle.innerHTML = '<i class="fa-regular fa-pen-to-square"></i> 原文輸入 (華)'; | |
| textInput.placeholder = `請在此輸入華,將自動翻譯為【${currentTribe}】...`; | |
| } else if (mode === 'native_to_zh') { | |
| inputBoxTitle.innerHTML = `<i class="fa-regular fa-pen-to-square"></i> 原文輸入 (${currentTribe})`; | |
| textInput.placeholder = `請在此輸入【${currentTribe}】,將自動翻譯為華...`; | |
| } else { | |
| inputBoxTitle.innerHTML = '<i class="fa-regular fa-pen-to-square"></i> 原文輸入 (自動判斷)'; | |
| textInput.placeholder = `請在此輸入句子,系統將自動判斷方向...`; | |
| } | |
| } | |
| } | |
| dirBtns.forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| const mode = btn.getAttribute('data-mode'); | |
| updateDirBtnsUI(mode); | |
| if (textInput.value.trim().length > 0) { | |
| handleTranslate(); | |
| } | |
| }); | |
| }); | |
| // 4. 族切換同步 | |
| tribeSelect.addEventListener('change', (e) => { | |
| currentTribe = e.target.value; | |
| document.querySelectorAll('.current-tribe-label').forEach(el => el.textContent = currentTribe); | |
| updateDirBtnsUI(currentMode); | |
| if (textInput.value.trim().length > 0) { | |
| if (currentMode === 'zh_to_native') { | |
| // 華翻族:切換族時自動將原華翻譯為新族 | |
| handleTranslate(); | |
| } else { | |
| // 族翻華:切換族時,原族已不符新選族,直接清空避免誤會 | |
| textInput.value = ''; | |
| textOutput.textContent = ''; | |
| textOutput.classList.add('empty'); | |
| lastTargetText = ''; | |
| updateCharCount(); | |
| // 隱藏信心值與功能按鈕 | |
| confidenceBadge.style.display = 'none'; | |
| ttsBtn.disabled = true; | |
| deepTranslateBtn.disabled = true; | |
| copyBtn.disabled = true; | |
| } | |
| } | |
| }); | |
| // 5. 字數統計與上限限制 | |
| function updateCharCount() { | |
| const len = textInput.value.length; | |
| charCount.textContent = `${len} 字`; | |
| charCount.style.color = 'var(--text-muted)'; | |
| } | |
| textInput.addEventListener('input', updateCharCount); | |
| // 6. 執行文字翻譯 (POST /api/translate) | |
| async function handleTranslate() { | |
| const text = textInput.value.trim(); | |
| if (!text) return; | |
| if (text.length > 300) { | |
| alert('您的翻譯字數超過300字,會需要一些翻譯等待時間,敬請稍後。'); | |
| } | |
| translateBtn.disabled = true; | |
| translateBtn.innerHTML = '<span class="loading-spinner"></span> 翻譯中...'; | |
| textOutput.classList.remove('empty'); | |
| textOutput.textContent = 'AI 正向遠端 API 請求翻譯中...'; | |
| const startTime = performance.now(); | |
| try { | |
| const res = await fetch('/api/translate', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ text: text, tribe: currentTribe, mode: currentMode }) | |
| }); | |
| const data = await res.json(); | |
| const endTime = performance.now(); | |
| const durationSec = ((endTime - startTime) / 1000).toFixed(1); | |
| speedTimer.innerHTML = `<i class="fa-regular fa-clock"></i> 耗時: ${durationSec}s`; | |
| if (data.error) { | |
| textOutput.textContent = `❌ ${data.error}`; | |
| return; | |
| } | |
| lastSourceText = text; | |
| lastTargetText = data.target; | |
| lastDirection = data.direction || 'zh_to_native'; | |
| lastNativeText = (data.direction === 'native_to_zh') ? text : data.target; | |
| lastChineseText = (data.direction === 'native_to_zh') ? data.target : text; | |
| textOutput.textContent = data.target; | |
| // 啟用按鈕 | |
| ttsBtn.disabled = false; | |
| deepTranslateBtn.disabled = false; | |
| copyBtn.disabled = false; | |
| // 顯示信心值與翻譯方向標籤 | |
| const dirLabel = data.direction === 'native_to_zh' ? `${currentTribe} ➔ 華` : `華 ➔ ${currentTribe}`; | |
| confidenceBadge.style.display = 'inline-flex'; | |
| confidenceBadge.className = `badge-confidence ${data.confidence_level || 'high'}`; | |
| confidenceLevel.textContent = data.confidence_level === 'low' ? '低' : (data.confidence_level === 'medium' ? '中' : '高'); | |
| confidenceDesc.textContent = `【${dirLabel}】 ${data.confidence_desc || '一般生活句'}`; | |
| // 記錄至本地歷史紀錄 | |
| saveToHistory(text, data.target, currentTribe); | |
| } catch (err) { | |
| textOutput.textContent = `❌ 網路請求失敗: ${err.message}`; | |
| } finally { | |
| translateBtn.disabled = false; | |
| translateBtn.innerHTML = '<i class="fa-solid fa-paper-plane"></i> 開始極速翻譯'; | |
| } | |
| } | |
| translateBtn.addEventListener('click', handleTranslate); | |
| // 雙向對調按鈕事件 | |
| const swapDirectionBtn = document.getElementById('swapDirectionBtn'); | |
| if (swapDirectionBtn) { | |
| swapDirectionBtn.addEventListener('click', () => { | |
| if (currentMode === 'zh_to_native') { | |
| updateDirBtnsUI('native_to_zh'); | |
| } else if (currentMode === 'native_to_zh') { | |
| updateDirBtnsUI('zh_to_native'); | |
| } | |
| if (lastTargetText) { | |
| textInput.value = lastTargetText; | |
| updateCharCount(); | |
| handleTranslate(); | |
| } else if (textInput.value.trim()) { | |
| handleTranslate(); | |
| } | |
| }); | |
| } | |
| // Enter 鍵按快捷翻譯 (Shift+Enter 換行) | |
| textInput.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| handleTranslate(); | |
| } | |
| }); | |
| // 6. 🔊 聽發音 (POST /api/tts - 傳送族文字) | |
| ttsBtn.addEventListener('click', async () => { | |
| const nativeTextToSpeak = lastNativeText || (lastDirection === 'native_to_zh' ? lastSourceText : lastTargetText); | |
| if (!nativeTextToSpeak) return; | |
| ttsBtn.disabled = true; | |
| ttsBtn.innerHTML = '<span class="loading-spinner"></span> 合成中...'; | |
| try { | |
| const res = await fetch('/api/tts', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ text: nativeTextToSpeak, tribe: currentTribe }) | |
| }); | |
| const data = await res.json(); | |
| if (data.audio_url) { | |
| if (currentAudio) currentAudio.pause(); | |
| currentAudio = new Audio(data.audio_url); | |
| currentAudio.play(); | |
| } else { | |
| alert('⚠️ 音合成失敗:' + (data.error || '未知錯誤')); | |
| } | |
| } catch (err) { | |
| alert('❌ 音服務請求失敗'); | |
| } finally { | |
| ttsBtn.disabled = false; | |
| ttsBtn.innerHTML = '<i class="fa-solid fa-volume-high"></i> 🔊 聽發音'; | |
| } | |
| }); | |
| // 8. 🧠 深度文化潤飾與 API Key 管理 | |
| const apiKeyModal = document.getElementById('apiKeyModal'); | |
| const closeApiKeyModalBtn = document.getElementById('closeApiKeyModalBtn'); | |
| const cancelApiKeyBtn = document.getElementById('cancelApiKeyBtn'); | |
| const confirmApiKeyBtn = document.getElementById('confirmApiKeyBtn'); | |
| const geminiApiKeyInput = document.getElementById('geminiApiKeyInput'); | |
| const rememberApiKeyCheckbox = document.getElementById('rememberApiKeyCheckbox'); | |
| const changeApiKeyBtn = document.getElementById('changeApiKeyBtn'); | |
| let pendingDeepTranslateResolve = null; | |
| function promptForApiKey() { | |
| return new Promise((resolve) => { | |
| pendingDeepTranslateResolve = resolve; | |
| const savedKey = localStorage.getItem('gemini_api_key') || ''; | |
| if (geminiApiKeyInput) { | |
| geminiApiKeyInput.value = savedKey; | |
| setTimeout(() => geminiApiKeyInput.focus(), 100); | |
| } | |
| if (apiKeyModal) apiKeyModal.style.display = 'flex'; | |
| }); | |
| } | |
| function closeApiKeyModal(apiKey = null) { | |
| if (apiKeyModal) apiKeyModal.style.display = 'none'; | |
| if (pendingDeepTranslateResolve) { | |
| pendingDeepTranslateResolve(apiKey); | |
| pendingDeepTranslateResolve = null; | |
| } | |
| } | |
| if (closeApiKeyModalBtn) closeApiKeyModalBtn.addEventListener('click', () => closeApiKeyModal(null)); | |
| if (cancelApiKeyBtn) cancelApiKeyBtn.addEventListener('click', () => closeApiKeyModal(null)); | |
| function handleApiKeySubmit() { | |
| const key = geminiApiKeyInput.value.trim(); | |
| if (!key) { | |
| alert('⚠️ 請輸入有效的 API Key'); | |
| return; | |
| } | |
| if (rememberApiKeyCheckbox && rememberApiKeyCheckbox.checked) { | |
| localStorage.setItem('gemini_api_key', key); | |
| } else { | |
| localStorage.removeItem('gemini_api_key'); | |
| } | |
| closeApiKeyModal(key); | |
| } | |
| if (confirmApiKeyBtn) { | |
| confirmApiKeyBtn.addEventListener('click', handleApiKeySubmit); | |
| } | |
| if (geminiApiKeyInput) { | |
| geminiApiKeyInput.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter') { | |
| e.preventDefault(); | |
| handleApiKeySubmit(); | |
| } | |
| }); | |
| } | |
| if (changeApiKeyBtn) { | |
| changeApiKeyBtn.addEventListener('click', async () => { | |
| const deepModal = document.getElementById('deepTranslateModal'); | |
| if (deepModal) deepModal.style.display = 'none'; | |
| const newKey = await promptForApiKey(); | |
| if (newKey && lastSourceText && lastTargetText) { | |
| await executeDeepTranslate(newKey); | |
| } | |
| }); | |
| } | |
| async function executeDeepTranslate(apiKeyToUse) { | |
| deepTranslateBtn.disabled = true; | |
| deepTranslateBtn.innerHTML = '<span class="loading-spinner"></span> 潤飾中...'; | |
| try { | |
| const res = await fetch('/api/deep_translate', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| source_text: lastSourceText, | |
| target_text: lastTargetText, | |
| tribe: currentTribe, | |
| api_key: apiKeyToUse | |
| }) | |
| }); | |
| const data = await res.json(); | |
| if (data.result) { | |
| const bodyElem = document.getElementById('deepTranslateResultBody'); | |
| const modal = document.getElementById('deepTranslateModal'); | |
| // 🎯 使用後端 is_api_key_error 旗標(精準、不依賴字串比對) | |
| if (data.is_api_key_error === true) { | |
| localStorage.removeItem('gemini_api_key'); | |
| // 直接寫入 innerHTML,用 onclick 屬性綁定事件(最穩定的方式,不依賴 addEventListener) | |
| bodyElem.innerHTML = [ | |
| '<div style="background:rgba(245,158,11,0.15);border:2px solid rgba(245,158,11,0.6);padding:24px;border-radius:14px;text-align:center;">', | |
| '<div style="font-size:2.5rem;margin-bottom:12px;">⚠️</div>', | |
| '<div style="color:#fbbf24;font-size:1.2rem;font-weight:700;margin-bottom:12px;">【API Key 驗證失敗】</div>', | |
| '<div style="color:#fef3c7;font-size:0.95rem;line-height:1.7;margin-bottom:20px;">', | |
| '您的 Google Gemini API Key 無效或已過期。<br>請點擊下方按鈕重新輸入有效金鑰。', | |
| '</div>', | |
| '<button onclick="window.__changeApiKey__()" style="background:#f59e0b;color:#000;font-weight:800;border:none;padding:14px 28px;border-radius:10px;cursor:pointer;font-size:1rem;box-shadow:0 4px 20px rgba(245,158,11,0.5);">', | |
| '🔑 點此立即更換 API Key', | |
| '</button>', | |
| '</div>' | |
| ].join(''); | |
| } else { | |
| bodyElem.textContent = data.result; | |
| } | |
| modal.style.display = 'flex'; | |
| } else { | |
| alert('⚠️ 深度翻譯服務未回傳結果'); | |
| } | |
| } catch (err) { | |
| alert('❌ 深度翻譯請求失敗'); | |
| } finally { | |
| deepTranslateBtn.disabled = false; | |
| deepTranslateBtn.innerHTML = '<i class="fa-solid fa-brain"></i> 🧠 深度文化潤飾'; | |
| } | |
| } | |
| // 🌐 全域函式:讓 onclick 屬性能呼叫到 promptForApiKey | |
| window.__changeApiKey__ = async function() { | |
| document.getElementById('deepTranslateModal').style.display = 'none'; | |
| const newKey = await promptForApiKey(); | |
| if (newKey && lastSourceText && lastTargetText) { | |
| await executeDeepTranslate(newKey); | |
| } | |
| }; | |
| deepTranslateBtn.addEventListener('click', async () => { | |
| if (!lastSourceText || !lastTargetText) return; | |
| // 🎯 點擊「深度文化潤飾」時強制跳出 API Key 彈窗 (若有預存金鑰會自動帶入) | |
| const apiKey = await promptForApiKey(); | |
| if (!apiKey) return; // 使用者按下取消 | |
| await executeDeepTranslate(apiKey); | |
| }); | |
| // 9. 複製結果 | |
| copyBtn.addEventListener('click', () => { | |
| if (!lastTargetText) return; | |
| navigator.clipboard.writeText(lastTargetText).then(() => { | |
| const originalText = copyBtn.innerHTML; | |
| copyBtn.innerHTML = '<i class="fa-solid fa-check"></i> 已複製!'; | |
| setTimeout(() => copyBtn.innerHTML = originalText, 2000); | |
| }); | |
| }); | |
| // 10. 🎙️ 音錄音與 ASR (POST /api/asr) | |
| const startRecordBtn = document.getElementById('startRecordBtn'); | |
| const stopRecordBtn = document.getElementById('stopRecordBtn'); | |
| const recordingStatus = document.getElementById('recordingStatus'); | |
| const audioDropzone = document.getElementById('audioDropzone'); | |
| const audioFileInput = document.getElementById('audioFileInput'); | |
| const audioResult = document.getElementById('audioResult'); | |
| const audioOutput = document.getElementById('audioOutput'); | |
| startRecordBtn.addEventListener('click', async () => { | |
| try { | |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); | |
| mediaRecorder = new MediaRecorder(stream); | |
| audioChunks = []; | |
| mediaRecorder.ondataavailable = event => audioChunks.push(event.data); | |
| mediaRecorder.onstop = async () => { | |
| const audioBlob = new Blob(audioChunks, { type: 'audio/wav' }); | |
| await sendAudioFile(audioBlob, 'recorded_audio.wav'); | |
| }; | |
| mediaRecorder.start(); | |
| recordingStatus.style.display = 'block'; | |
| startRecordBtn.disabled = true; | |
| stopRecordBtn.disabled = false; | |
| } catch (err) { | |
| alert('⚠️ 無法存取麥克風設備: ' + err.message); | |
| } | |
| }); | |
| stopRecordBtn.addEventListener('click', () => { | |
| if (mediaRecorder && mediaRecorder.state !== 'inactive') { | |
| mediaRecorder.stop(); | |
| recordingStatus.style.display = 'none'; | |
| startRecordBtn.disabled = false; | |
| stopRecordBtn.disabled = true; | |
| } | |
| }); | |
| audioDropzone.addEventListener('click', () => audioFileInput.click()); | |
| audioFileInput.addEventListener('change', (e) => { | |
| if (e.target.files.length > 0) { | |
| sendAudioFile(e.target.files[0], e.target.files[0].name); | |
| } | |
| }); | |
| async function sendAudioFile(blobOrFile, filename) { | |
| audioResult.style.display = 'block'; | |
| audioOutput.textContent = '音音軌分析中,請稍候...'; | |
| const formData = new FormData(); | |
| formData.append('file', blobOrFile, filename); | |
| formData.append('tribe', currentTribe); | |
| try { | |
| const res = await fetch('/api/asr', { method: 'POST', body: formData }); | |
| const data = await res.json(); | |
| if (data.error) { | |
| audioOutput.textContent = `❌ ${data.error}`; | |
| } else { | |
| audioOutput.innerHTML = ` | |
| <div><strong>辨識文字:</strong> ${data.source || '無文本'}</div> | |
| <div style="margin-top: 8px; color: var(--primary-cyan);"><strong>【${currentTribe}翻譯】:</strong> ${data.target || '無翻譯'}</div> | |
| `; | |
| } | |
| } catch (err) { | |
| audioOutput.textContent = `❌ 音分析傳送失敗: ${err.message}`; | |
| } | |
| } | |
| // 11. 🖼️ 圖片 OCR 翻譯 (POST /api/ocr) | |
| const imageDropzone = document.getElementById('imageDropzone'); | |
| const imageFileInput = document.getElementById('imageFileInput'); | |
| const imageResult = document.getElementById('imageResult'); | |
| const imageOutput = document.getElementById('imageOutput'); | |
| imageDropzone.addEventListener('click', () => imageFileInput.click()); | |
| imageFileInput.addEventListener('change', (e) => { | |
| if (e.target.files.length > 0) { | |
| sendImageFile(e.target.files[0]); | |
| } | |
| }); | |
| async function sendImageFile(file) { | |
| imageResult.style.display = 'block'; | |
| imageOutput.textContent = 'Gemini 視覺模型解析圖片中...'; | |
| // 顯示圖片預覽 | |
| const imagePreviewContainer = document.getElementById('imagePreviewContainer'); | |
| const imagePreview = document.getElementById('imagePreview'); | |
| const reader = new FileReader(); | |
| reader.onload = function(e) { | |
| imagePreview.src = e.target.result; | |
| imagePreviewContainer.style.display = 'block'; | |
| }; | |
| reader.readAsDataURL(file); | |
| let currentOcrItems = []; | |
| const formData = new FormData(); | |
| formData.append('file', file); | |
| formData.append('tribe', currentTribe); | |
| try { | |
| const res = await fetch('/api/ocr', { method: 'POST', body: formData }); | |
| const data = await res.json(); | |
| if (data.items && data.items.length > 0) { | |
| currentOcrItems = data.items; | |
| imageOutput.innerHTML = ''; | |
| data.items.forEach(item => { | |
| const card = document.createElement('div'); | |
| card.style.cssText = 'background: rgba(0,0,0,0.4); padding: 12px; border-radius: 8px; border-left: 3px solid var(--primary-cyan);'; | |
| card.innerHTML = ` | |
| <div style="color: var(--text-muted); font-size: 0.85rem;">原圖文字: ${item.original}</div> | |
| <div style="color: var(--primary-cyan); font-weight: 600; font-size: 1.1rem; margin-top: 4px;">譯文: ${item.translated}</div> | |
| `; | |
| imageOutput.appendChild(card); | |
| }); | |
| } else { | |
| imageOutput.textContent = '⚠️ 未能在圖片中辨識出有效文字,或 OCR 回傳空值。'; | |
| } | |
| } catch (err) { | |
| imageOutput.textContent = `❌ 圖片上傳解析失敗: ${err.message}`; | |
| } | |
| } | |
| // 複製 OCR 結果 | |
| const copyOcrBtn = document.getElementById('copyOcrBtn'); | |
| if(copyOcrBtn) { | |
| copyOcrBtn.addEventListener('click', () => { | |
| const outputText = Array.from(imageOutput.children) | |
| .filter(card => card.tagName.toLowerCase() === 'div') | |
| .map(card => { | |
| const orig = card.querySelector('div:nth-child(1)').textContent.replace('原圖文字: ', ''); | |
| const trans = card.querySelector('div:nth-child(2)').textContent.replace('譯文: ', ''); | |
| return `【原圖文字】: ${orig}\n【太魯閣翻譯】: ${trans}`; | |
| }).join('\n\n'); | |
| if (!outputText) return; | |
| navigator.clipboard.writeText(outputText); | |
| const originalText = copyOcrBtn.innerHTML; | |
| copyOcrBtn.innerHTML = '<i class="fa-solid fa-check"></i> 已複製'; | |
| setTimeout(() => copyOcrBtn.innerHTML = originalText, 2000); | |
| }); | |
| } | |
| // 下載 OCR WORD | |
| const downloadOcrWordBtn = document.getElementById('downloadOcrWordBtn'); | |
| if(downloadOcrWordBtn) { | |
| downloadOcrWordBtn.addEventListener('click', () => { | |
| const outputText = Array.from(imageOutput.children) | |
| .filter(card => card.tagName.toLowerCase() === 'div') | |
| .map(card => { | |
| const orig = card.querySelector('div:nth-child(1)').textContent.replace('原圖文字: ', ''); | |
| const trans = card.querySelector('div:nth-child(2)').textContent.replace('譯文: ', ''); | |
| return `<p style="font-family: Arial, sans-serif; font-size: 14pt; color: #555;">【原圖文字】: ${orig}</p> | |
| <p style="font-family: Arial, sans-serif; font-size: 16pt; color: #008080; font-weight: bold; margin-bottom: 20px;">【太魯閣翻譯】: ${trans}</p>`; | |
| }).join(''); | |
| if (!outputText) return; | |
| const htmlContent = ` | |
| <html xmlns:o='urn:schemas-microsoft-com:office:office' xmlns:w='urn:schemas-microsoft-com:office:word' xmlns='http://www.w3.org/TR/REC-html40'> | |
| <head><meta charset='utf-8'><title>OCR 翻譯結果</title></head> | |
| <body> | |
| <h1 style="text-align: center; color: #333;">圖片 OCR 翻譯結果</h1> | |
| <hr> | |
| ${outputText} | |
| </body> | |
| </html> | |
| `; | |
| const blob = new Blob(['\ufeff', htmlContent], { type: 'application/msword' }); | |
| const url = URL.createObjectURL(blob); | |
| const link = document.createElement('a'); | |
| link.href = url; | |
| link.download = getTimestampedFileName('圖片OCR翻譯結果.doc'); | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| URL.revokeObjectURL(url); | |
| }); | |
| } | |
| // 12. 🎬 影片音軌翻譯 (POST /api/video) | |
| const videoDropzone = document.getElementById('videoDropzone'); | |
| const videoFileInput = document.getElementById('videoFileInput'); | |
| const videoResult = document.getElementById('videoResult'); | |
| const videoOutput = document.getElementById('videoOutput'); | |
| videoDropzone.addEventListener('click', () => videoFileInput.click()); | |
| videoFileInput.addEventListener('change', (e) => { | |
| if (e.target.files.length > 0) { | |
| sendVideoFile(e.target.files[0]); | |
| } | |
| }); | |
| let currentVideoData = null; | |
| // 格式化秒數為 SRT 時間軸 (HH:MM:SS,MMM) | |
| function formatSRTTime(seconds) { | |
| const date = new Date(0); | |
| date.setSeconds(Math.floor(seconds)); | |
| const hh = String(date.getUTCHours()).padStart(2, '0'); | |
| const mm = String(date.getUTCMinutes()).padStart(2, '0'); | |
| const ss = String(date.getUTCSeconds()).padStart(2, '0'); | |
| const mmm = String(Math.floor((seconds % 1) * 1000)).padStart(3, '0'); | |
| return `${hh}:${mm}:${ss},${mmm}`; | |
| } | |
| async function sendVideoFile(file) { | |
| videoResult.style.display = 'block'; | |
| videoOutput.textContent = 'MoviePy 提取 16kHz 音軌與 ASR 意識別中 (影片較大時需數秒時間)...'; | |
| // 顯示影片預覽 | |
| const videoPreviewContainer = document.getElementById('videoPreviewContainer'); | |
| const videoPreview = document.getElementById('videoPreview'); | |
| const fileUrl = URL.createObjectURL(file); | |
| videoPreview.src = fileUrl; | |
| videoPreviewContainer.style.display = 'block'; | |
| currentVideoData = null; // 重置資料 | |
| const formData = new FormData(); | |
| formData.append('file', file); | |
| formData.append('tribe', currentTribe); | |
| try { | |
| const res = await fetch('/api/video', { method: 'POST', body: formData }); | |
| const data = await res.json(); | |
| if (data.error) { | |
| videoOutput.textContent = `❌ ${data.error}`; | |
| } else { | |
| currentVideoData = data; // 保存資料供按鈕使用 | |
| // 渲染各個片段 | |
| videoOutput.innerHTML = ''; | |
| if (data.segments && data.segments.length > 0) { | |
| data.segments.sort((a, b) => a.start - b.start).forEach((seg, idx) => { | |
| const card = document.createElement('div'); | |
| card.style.cssText = 'background: rgba(0,0,0,0.4); padding: 12px; border-radius: 8px; border-left: 3px solid var(--primary-cyan); margin-bottom: 10px;'; | |
| const timeRange = `[${formatSRTTime(seg.start).split(',')[0]} - ${formatSRTTime(seg.end).split(',')[0]}]`; | |
| card.innerHTML = ` | |
| <div style="font-size: 0.8rem; color: var(--text-dim); margin-bottom: 6px;"> | |
| <i class="fa-regular fa-clock"></i> 區段 ${idx + 1} ${timeRange} | |
| </div> | |
| <div style="color: var(--text-muted); font-size: 0.95rem;"><strong>提取音逐字稿:</strong> <span>${seg.source}</span></div> | |
| <div style="color: var(--primary-cyan); font-weight: 600; font-size: 1.1rem; margin-top: 4px;"><strong>【${currentTribe}對譯】:</strong> <span>${seg.target}</span></div> | |
| `; | |
| videoOutput.appendChild(card); | |
| }); | |
| } else { | |
| videoOutput.innerHTML = `<div style="color: var(--text-dim);">無法辨識出任何有效語音區段。</div>`; | |
| } | |
| } | |
| } catch (err) { | |
| videoOutput.textContent = `❌ 影片處理失敗: ${err.message}`; | |
| } | |
| } | |
| // 影片模組按鈕綁定 | |
| const downloadAudioBtn = document.getElementById('downloadAudioBtn'); | |
| const downloadSrtBtn = document.getElementById('downloadSrtBtn'); | |
| const copyVideoBtn = document.getElementById('copyVideoBtn'); | |
| const downloadVideoWordBtn = document.getElementById('downloadVideoWordBtn'); | |
| if(downloadAudioBtn) { | |
| downloadAudioBtn.addEventListener('click', () => { | |
| if (!currentVideoData || !currentVideoData.audio_url) { | |
| alert('無可用的音軌檔案。'); | |
| return; | |
| } | |
| const link = document.createElement('a'); | |
| link.href = currentVideoData.audio_url; | |
| link.download = getTimestampedFileName('影片提取音軌.wav'); | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| }); | |
| } | |
| if(downloadSrtBtn) { | |
| downloadSrtBtn.addEventListener('click', () => { | |
| if (!currentVideoData || !currentVideoData.segments) { | |
| alert('請先等待影片辨識完成或確認有辨識出段落。'); | |
| return; | |
| } | |
| let srtContent = ''; | |
| currentVideoData.segments.sort((a, b) => a.start - b.start).forEach((seg, idx) => { | |
| const startTime = formatSRTTime(seg.start); | |
| const endTime = formatSRTTime(seg.end); | |
| srtContent += `${idx + 1}\n${startTime} --> ${endTime}\n${seg.source}\n${seg.target}\n\n`; | |
| }); | |
| const blob = new Blob([srtContent], { type: 'text/plain' }); | |
| const url = URL.createObjectURL(blob); | |
| const link = document.createElement('a'); | |
| link.href = url; | |
| link.download = getTimestampedFileName('影片翻譯字幕.srt'); | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| URL.revokeObjectURL(url); | |
| }); | |
| } | |
| if(copyVideoBtn) { | |
| copyVideoBtn.addEventListener('click', () => { | |
| if (!currentVideoData || !currentVideoData.segments) return; | |
| const outputText = currentVideoData.segments.sort((a, b) => a.start - b.start).map((seg, idx) => { | |
| return `[區段 ${idx + 1}]\n【原音逐字稿】: ${seg.source}\n【${currentVideoData.tribe}翻譯】: ${seg.target}`; | |
| }).join('\n\n'); | |
| navigator.clipboard.writeText(outputText); | |
| const originalText = copyVideoBtn.innerHTML; | |
| copyVideoBtn.innerHTML = '<i class="fa-solid fa-check"></i> 已複製'; | |
| setTimeout(() => copyVideoBtn.innerHTML = originalText, 2000); | |
| }); | |
| } | |
| if(downloadVideoWordBtn) { | |
| downloadVideoWordBtn.addEventListener('click', () => { | |
| if (!currentVideoData || !currentVideoData.segments) return; | |
| const outputText = currentVideoData.segments.sort((a, b) => a.start - b.start).map((seg, idx) => { | |
| return `<p style="font-family: Arial, sans-serif; font-size: 11pt; color: #888;">[區段 ${idx + 1}]</p> | |
| <p style="font-family: Arial, sans-serif; font-size: 14pt; color: #555;">【原音逐字稿】: ${seg.source}</p> | |
| <p style="font-family: Arial, sans-serif; font-size: 16pt; color: #008080; font-weight: bold; margin-bottom: 20px;">【${currentVideoData.tribe}翻譯】: ${seg.target}</p>`; | |
| }).join(''); | |
| const htmlContent = ` | |
| <html xmlns:o='urn:schemas-microsoft-com:office:office' xmlns:w='urn:schemas-microsoft-com:office:word' xmlns='http://www.w3.org/TR/REC-html40'> | |
| <head><meta charset='utf-8'><title>影片翻譯結果</title></head> | |
| <body> | |
| <h1 style="text-align: center; color: #333;">影片翻譯結果</h1> | |
| <hr> | |
| ${outputText} | |
| </body> | |
| </html> | |
| `; | |
| const blob = new Blob(['\ufeff', htmlContent], { type: 'application/msword' }); | |
| const url = URL.createObjectURL(blob); | |
| const link = document.createElement('a'); | |
| link.href = url; | |
| link.download = getTimestampedFileName('影片翻譯結果.doc'); | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| URL.revokeObjectURL(url); | |
| }); | |
| } | |
| // 13. 💬 送出報錯與建議 (POST /api/feedback) | |
| const sendFeedbackBtn = document.getElementById('sendFeedbackBtn'); | |
| sendFeedbackBtn.addEventListener('click', async () => { | |
| const nickname = document.getElementById('fbNickname').value.trim() || '熱心使用者'; | |
| const comment = document.getElementById('fbComment').value.trim(); | |
| if (!comment) { | |
| alert('請填寫回饋與建議內容!'); | |
| return; | |
| } | |
| sendFeedbackBtn.disabled = true; | |
| sendFeedbackBtn.innerHTML = '<span class="loading-spinner"></span> 發送中...'; | |
| try { | |
| const res = await fetch('/api/feedback', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ user_nickname: nickname, tribe: currentTribe, comment: comment }) | |
| }); | |
| const data = await res.json(); | |
| alert('✅ 感謝您的寶貴回饋!意見已成功寄送給料維護團隊。'); | |
| document.getElementById('fbComment').value = ''; | |
| } catch (err) { | |
| alert('❌ 寄送失敗,請稍後再試。'); | |
| } finally { | |
| sendFeedbackBtn.disabled = false; | |
| sendFeedbackBtn.innerHTML = '<i class="fa-solid fa-envelope"></i> 送出意見回饋'; | |
| } | |
| }); | |
| // 14. 歷史紀錄儲存 | |
| function saveToHistory(source, target, tribe) { | |
| let history = JSON.parse(localStorage.getItem('ilrdf_history') || '[]'); | |
| history.unshift({ source, target, tribe, time: new Date().toLocaleTimeString() }); | |
| history = history.slice(0, 8); // 保留最新 8 條 | |
| localStorage.setItem('ilrdf_history', JSON.stringify(history)); | |
| renderHistory(); | |
| } | |
| function renderHistory() { | |
| let history = JSON.parse(localStorage.getItem('ilrdf_history') || '[]'); | |
| if (history.length === 0) { | |
| historyList.innerHTML = '<div style="color: var(--text-dim); font-size: 0.85rem; padding: 10px;">尚未有翻譯紀錄。</div>'; | |
| return; | |
| } | |
| historyList.innerHTML = ''; | |
| history.forEach(h => { | |
| const item = document.createElement('div'); | |
| item.className = 'history-item'; | |
| item.innerHTML = ` | |
| <div> | |
| <div><strong>${h.source}</strong> → <span style="color: var(--primary-cyan);">${h.target}</span></div> | |
| <div style="font-size: 0.75rem; color: var(--text-dim);">${h.tribe} | ${h.time}</div> | |
| </div> | |
| <button class="btn btn-secondary" style="padding: 4px 8px; font-size: 0.75rem;">帶入</button> | |
| `; | |
| item.querySelector('button').addEventListener('click', () => { | |
| textInput.value = h.source; | |
| tribeSelect.value = h.tribe; | |
| currentTribe = h.tribe; | |
| updateCharCount(); | |
| handleTranslate(); | |
| }); | |
| historyList.appendChild(item); | |
| }); | |
| } | |
| // 初始化速查句卡與歷史紀錄 | |
| initPhrasebook(); | |
| renderHistory(); | |
| // 清除歷史紀錄 (綁定為全域函式,讓 HTML onclick 可以呼叫) | |
| window.clearHistory = function() { | |
| if (!confirm('確定要清除全部翻譯歷史紀錄嗎?此操作無法復原。')) return; | |
| localStorage.removeItem('ilrdf_history'); | |
| renderHistory(); | |
| // 給按鈕一個短暫的視覺回饋 | |
| const btn = document.getElementById('clearHistoryBtn'); | |
| if (btn) { | |
| const orig = btn.innerHTML; | |
| btn.innerHTML = '<i class="fa-solid fa-check"></i> 已清除'; | |
| btn.style.color = '#4ade80'; | |
| btn.style.borderColor = 'rgba(74,222,128,0.4)'; | |
| setTimeout(() => { | |
| btn.innerHTML = orig; | |
| btn.style.color = '#ff6b6b'; | |
| btn.style.borderColor = 'rgba(255,80,80,0.4)'; | |
| }, 1500); | |
| } | |
| }; | |
| // 15. 深度文化潤飾 Modal 事件綁定 | |
| const deepTranslateModal = document.getElementById('deepTranslateModal'); | |
| const closeModalBtn = document.getElementById('closeModalBtn'); | |
| const copyDeepResultBtn = document.getElementById('copyDeepResultBtn'); | |
| const downloadWordBtn = document.getElementById('downloadWordBtn'); | |
| closeModalBtn.addEventListener('click', () => { | |
| deepTranslateModal.style.display = 'none'; | |
| }); | |
| // 點擊背景關閉 | |
| deepTranslateModal.addEventListener('click', (e) => { | |
| if (e.target === deepTranslateModal) { | |
| deepTranslateModal.style.display = 'none'; | |
| } | |
| }); | |
| if (apiKeyModal) { | |
| apiKeyModal.addEventListener('click', (e) => { | |
| if (e.target === apiKeyModal) { | |
| closeApiKeyModal(null); | |
| } | |
| }); | |
| } | |
| // 複製結果 | |
| copyDeepResultBtn.addEventListener('click', () => { | |
| const content = document.getElementById('deepTranslateResultBody').textContent; | |
| navigator.clipboard.writeText(content).then(() => { | |
| const originalText = copyDeepResultBtn.innerHTML; | |
| copyDeepResultBtn.innerHTML = '<i class="fa-solid fa-check"></i> 已複製'; | |
| setTimeout(() => copyDeepResultBtn.innerHTML = originalText, 2000); | |
| }); | |
| }); | |
| // 下載 WORD | |
| downloadWordBtn.addEventListener('click', () => { | |
| const content = document.getElementById('deepTranslateResultBody').textContent; | |
| // 簡單生成相容 Word 的 HTML 格式 doc | |
| const htmlContent = ` | |
| <html xmlns:o='urn:schemas-microsoft-com:office:office' xmlns:w='urn:schemas-microsoft-com:office:word' xmlns='http://www.w3.org/TR/REC-html40'> | |
| <head><meta charset='utf-8'><title>深度文化潤飾結果</title></head> | |
| <body> | |
| <div style="font-family: 'Microsoft JhengHei', sans-serif; font-size: 14pt; line-height: 1.5; white-space: pre-wrap;">${content}</div> | |
| </body> | |
| </html> | |
| `; | |
| const blob = new Blob(['\ufeff', htmlContent], { type: 'application/msword' }); | |
| const url = URL.createObjectURL(blob); | |
| const link = document.createElement('a'); | |
| link.href = url; | |
| link.download = getTimestampedFileName('深度文化潤飾結果.doc'); | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| URL.revokeObjectURL(url); | |
| }); | |
| // 16. 長句測試區事件綁定 | |
| const longPhraseZh = document.getElementById('longPhraseZh'); | |
| const longPhraseNative = document.getElementById('longPhraseNative'); | |
| if (longPhraseZh) { | |
| longPhraseZh.addEventListener('click', () => { | |
| textInput.value = '很久以前,天地間只有黑暗沒有白天,人類的生活很辛苦而且沒有發展。就這樣以松樹的木片點火當作燈來做家事及到田間工作。'; | |
| updateCharCount(); | |
| updateDirBtnsUI('zh_to_native'); | |
| }); | |
| } | |
| if (longPhraseNative) { | |
| longPhraseNative.addEventListener('click', () => { | |
| textInput.value = 'Mqaras ku bi qmita sunan, Lituk ka hangan mu, ima ka hangan su? Emptgsa ku ka yaku, ga ku tmgsa kari Truku. Gaga su qmpah qpahun manu?'; | |
| updateCharCount(); | |
| updateDirBtnsUI('native_to_zh'); | |
| }); | |
| } | |
| }); |