// ========================================================= // 🛡️ 共用安全工具:資料庫與 AI 回傳的文字一律先跳脫再放進 innerHTML // ========================================================= function escapeHtml(value) { return String(value ?? '').replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch])); } // 產生可安全放進 onclick="fn(...)" 的字串參數(句子含引號時也不會截斷屬性) function jsArg(value) { return escapeHtml(JSON.stringify(String(value ?? ''))); } // 先切出符合 regex 的片段,再各自跳脫,只有標示用的標籤是 HTML function highlightEscaped(text, regex, openTag) { const str = String(text ?? ''); if (!regex) return escapeHtml(str); const globalRegex = new RegExp(regex.source, regex.flags.includes('g') ? regex.flags : regex.flags + 'g'); let out = ''; let last = 0; for (const m of str.matchAll(globalRegex)) { if (!m[0]) continue; out += escapeHtml(str.slice(last, m.index)) + openTag + escapeHtml(m[0]) + ''; last = m.index + m[0].length; } return out + escapeHtml(str.slice(last)); } // AI 回傳的 Markdown 轉成 HTML 後再清理,避免夾帶可執行的標籤 function sanitizeHtml(html) { if (window.DOMPurify) return DOMPurify.sanitize(html); const div = document.createElement('div'); div.textContent = html; return div.innerHTML; } // ========================================================= // 🔐 後台管理員狀態 // 一般使用者只能「送出建議」;管理員從「後台登入」登入後才能直接寫入,並看到建議審核頁 // ========================================================= let isAdmin = false; // 瀏覽器只保存登入後拿到的「工作階段憑證」(8 小時後失效),不保存密碼; // 存在 sessionStorage,關掉分頁就會清除 const ADMIN_SESSION_KEY = 'truku_admin_session'; try { localStorage.removeItem('truku_admin_token'); } catch {} // 清掉舊版直接存密碼的紀錄 function getAdminToken() { try { return sessionStorage.getItem(ADMIN_SESSION_KEY) || ''; } catch { return ''; } } function setAdminToken(token) { try { if (token) sessionStorage.setItem(ADMIN_SESSION_KEY, token); else sessionStorage.removeItem(ADMIN_SESSION_KEY); } catch {} } function adminHeaders(extra = {}) { return { ...extra, 'X-Admin-Session': getAdminToken() }; } // 管理員專用 API;若登入已逾時,自動切回一般使用者模式並提醒重新登入 async function adminFetch(url, options = {}) { const res = await fetch(url, { ...options, headers: adminHeaders(options.headers || {}) }); if (res.status === 403 && isAdmin) { const data = await res.clone().json().catch(() => ({})); if (data.error === 'ADMIN_REQUIRED') { setAdminToken(''); isAdmin = false; applyAdminUi(); alert('⏰ 後台登入已逾時或失效,請重新從「🔐 後台登入」登入。'); } } return res; } async function refreshAdminState() { try { const res = await fetch('/api/admin/check', { headers: adminHeaders() }); isAdmin = res.ok; } catch { isAdmin = false; } applyAdminUi(); return isAdmin; } // 依登入狀態切換登入按鈕、審核頁籤,以及標注結果上的按鈕文字 function applyAdminUi() { const loginBtn = document.getElementById('adminLoginBtn'); if (loginBtn) loginBtn.textContent = isAdmin ? '🔓 登出後台' : '🔐 後台登入'; const reviewTabBtn = document.getElementById('tab-btn-review'); if (reviewTabBtn) { reviewTabBtn.classList.toggle('hidden', !isAdmin); if (!isAdmin && reviewTabBtn.classList.contains('active')) { document.querySelector('[data-tab="tab-search"]').click(); } } // 使用量儀表板含其他使用者的搜尋紀錄,只給管理員看 const usageSubtabBtn = document.getElementById('subtab-btn-usage'); if (usageSubtabBtn) { usageSubtabBtn.classList.toggle('hidden', !isAdmin); if (!isAdmin && usageSubtabBtn.classList.contains('active')) { document.querySelector('.sub-tab-btn:not(#subtab-btn-usage)')?.click(); } } document.querySelectorAll('.igt-save-btn').forEach(btn => { btn.textContent = igtSaveLabel(); }); document.querySelectorAll('.igt-import-btn').forEach(btn => { btn.textContent = igtImportLabel(); }); } function igtSaveLabel() { return isAdmin ? '💾 儲存至記憶庫' : '💡 提交校正建議'; } function igtImportLabel() { return isAdmin ? '📖 匯入為主辭典例句' : '💡 建議加入主辭典'; } async function submitSuggestion(body) { const res = await fetch('/api/suggestions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (!res.ok) throw new Error(await readErrorMessage(res, '送出失敗,請稍後再試。')); return res.json(); } document.addEventListener('DOMContentLoaded', () => { const loginBtn = document.getElementById('adminLoginBtn'); if (loginBtn) { loginBtn.addEventListener('click', async () => { if (isAdmin) { if (!confirm('確定要登出後台嗎?')) return; // 通知伺服器作廢這組憑證,再清掉瀏覽器裡的紀錄 await fetch('/api/admin/logout', { method: 'POST', headers: adminHeaders() }).catch(() => {}); setAdminToken(''); await refreshAdminState(); if (isAdmin) alert('ℹ️ 目前是本機開發模式(伺服器未設定 ADMIN_TOKEN),不需要密碼即有管理員權限。'); return; } const password = prompt('請輸入後台管理員密碼:'); if (!password) return; try { const res = await fetch('/api/admin/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.message || '登入失敗'); setAdminToken(data.token || ''); } catch (e) { alert(`❌ ${e.message}`); return; } if (await refreshAdminState()) { alert('✅ 已登入後台(8 小時後或關閉分頁時自動登出)。現在可以直接寫入,並在「📥 建議審核」頁審核使用者的建議。'); } else { setAdminToken(''); alert('❌ 登入失敗,請再試一次。'); } }); } refreshAdminState(); }); // 從失敗的回應取出可顯示的錯誤訊息 async function readErrorMessage(res, fallback) { const data = await res.json().catch(() => ({})); return data.message || data.error || data.detail || fallback; } document.addEventListener('DOMContentLoaded', () => { const searchInput = document.getElementById('searchInput'); const searchBtn = document.getElementById('searchBtn'); // Split layout elements const splitLayout = document.getElementById('split-layout'); const resultsList = document.getElementById('results-list'); const searchStatsSummary = document.getElementById('search-stats-summary'); const resultDetailCard = document.getElementById('result-detail-card'); const emptyDetailState = document.getElementById('empty-detail-state'); const loadingEl = document.getElementById('loading'); const noResultsEl = document.getElementById('no-results'); const noResultsDefaultHtml = noResultsEl ? noResultsEl.innerHTML : ''; const exportCsvBtn = document.getElementById('exportCsvBtn'); // API Key Settings const apiSettingsBtn = document.getElementById('apiSettingsBtn'); if (apiSettingsBtn) { apiSettingsBtn.addEventListener('click', () => { const currentKey = localStorage.getItem('custom_gemini_api_key') || ''; const newKey = prompt('系統已內建公用額度。若額度已用完,請填寫您個人的 Google Gemini API Key:\n(若要恢復使用系統預設額度,請清空並按確定)', currentKey); if (newKey !== null) { localStorage.setItem('custom_gemini_api_key', newKey.trim()); if (newKey.trim()) { alert('✅ 專屬 API Key 已儲存!接下來的標注將優先使用您的個人額度。'); } else { alert('🔄 已清除自訂 API Key,將恢復使用系統公用額度。'); } } }); } // Tab elements const tabBtns = document.querySelectorAll('.tab-btn'); const tabContents = document.querySelectorAll('.tab-content'); // Stats elements const statsLoading = document.getElementById('stats-loading'); const statsDashboard = document.getElementById('stats-dashboard'); let statsLoaded = false; let letterChart = null; let wordChart = null; let prefixChart = null; let suffixChart = null; let corpusPieChart = null; let currentResults = []; let currentQuery = ''; let currentSearchType = ''; // --- Tab Switching Logic --- tabBtns.forEach(btn => { btn.addEventListener('click', () => { const targetId = btn.getAttribute('data-tab'); tabBtns.forEach(b => b.classList.remove('active')); tabContents.forEach(c => c.classList.remove('active')); btn.classList.add('active'); document.getElementById(targetId).classList.add('active'); if (targetId === 'tab-stats' && !statsLoaded) { loadStats(); } else if (targetId === 'tab-projects') { loadProjectsList(); } else if (targetId === 'tab-review') { loadReviewTab(); } }); }); // --- Search Logic --- searchBtn.addEventListener('click', performSearch); searchInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') { performSearch(); } }); exportCsvBtn.addEventListener('click', () => { if (!currentQuery) return; const searchType = document.querySelector('input[name="searchType"]:checked').value; const matchMode = document.querySelector('input[name="matchMode"]:checked').value; const caseSensitive = document.getElementById('caseSensitiveCheck')?.checked || false; const corpusCheckboxes = document.querySelectorAll('input[name="corpus"]:checked'); let corporaParams = ''; if (corpusCheckboxes.length > 0) { const corpora = Array.from(corpusCheckboxes).map(cb => cb.value); corporaParams = '&' + corpora.map(c => `corpora[]=${encodeURIComponent(c)}`).join('&'); } const exportUrl = `/api/export?q=${encodeURIComponent(currentQuery)}&type=${encodeURIComponent(searchType)}&match_mode=${encodeURIComponent(matchMode)}&case_sensitive=${caseSensitive}${corporaParams}`; // 用 fetch 下載:才能帶管理員憑證(管理員不受匯出上限),並在超過上限時顯示提示 fetch(exportUrl, { headers: adminHeaders() }) .then(async res => { if (!res.ok) throw new Error(await readErrorMessage(res, '匯出失敗')); const truncated = res.headers.get('X-Export-Truncated') === '1'; const total = res.headers.get('X-Export-Total'); const blob = await res.blob(); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'truku_search_export.csv'; a.click(); URL.revokeObjectURL(a.href); if (truncated) alert(`ℹ️ 共 ${total} 句符合,一次最多匯出 500 句,已匯出前 500 句。\n如需其他句子,請縮小搜尋範圍。`); }) .catch(e => alert(`❌ ${e.message}`)); }); async function performSearch() { let query = searchInput.value.trim(); if (!query) return; // 自動偵測構詞型態語法並自動切換選項 if ((query.startsWith('-') && query.endsWith('-') && query.length > 2) || query.startsWith('<') || query.startsWith('>')) { const infixRadio = document.querySelector('input[name="matchMode"][value="infix"]'); if (infixRadio) infixRadio.checked = true; } else if (query.startsWith('-') && query.length > 1 && !query.endsWith('-')) { const suffixRadio = document.querySelector('input[name="matchMode"][value="suffix"]'); if (suffixRadio) suffixRadio.checked = true; } else if (query.endsWith('-') && query.length > 1 && !query.startsWith('-')) { const prefixRadio = document.querySelector('input[name="matchMode"][value="prefix"]'); if (prefixRadio) prefixRadio.checked = true; } // Get options const searchType = document.querySelector('input[name="searchType"]:checked').value; const matchMode = document.querySelector('input[name="matchMode"]:checked').value; const caseSensitive = document.getElementById('caseSensitiveCheck')?.checked || false; const corpusCheckboxes = document.querySelectorAll('input[name="corpus"]:checked'); let corporaParams = ''; if (corpusCheckboxes.length > 0) { const corpora = Array.from(corpusCheckboxes).map(cb => cb.value); corporaParams = '&' + corpora.map(c => `corpora[]=${encodeURIComponent(c)}`).join('&'); } // Reset UI resultsList.innerHTML = ''; searchStatsSummary.innerHTML = ''; resultDetailCard.innerHTML = ''; splitLayout.classList.add('hidden'); searchStatsSummary.classList.add('hidden'); resultDetailCard.innerHTML = ''; emptyDetailState.style.display = 'flex'; noResultsEl.classList.add('hidden'); noResultsEl.innerHTML = noResultsDefaultHtml; loadingEl.classList.remove('hidden'); exportCsvBtn.classList.add('hidden'); try { const url = `/api/search?q=${encodeURIComponent(query)}&type=${encodeURIComponent(searchType)}&match_mode=${encodeURIComponent(matchMode)}&case_sensitive=${caseSensitive}${corporaParams}`; const response = await fetch(url, { headers: adminHeaders() }); if (!response.ok) { const err = new Error(await readErrorMessage(response, '發生錯誤,無法取得資料。')); err.userFacing = response.status === 400 || response.status === 429; throw err; } const data = await response.json(); loadingEl.classList.add('hidden'); if (data.results.length === 0) { noResultsEl.classList.remove('hidden'); } else { currentResults = data.results; currentQuery = query; currentSearchType = searchType; splitLayout.classList.remove('hidden'); exportCsvBtn.classList.remove('hidden'); renderSearchSummary(data, query, searchType, matchMode, caseSensitive); renderResultsList(data.results, query, searchType, caseSensitive); } // ── 記錄搜尋事件 ── logEvent('search', { query, search_type: searchType, match_mode: matchMode, case_sensitive: caseSensitive, result_count: data.results.length }); } catch (error) { console.error('Error fetching data:', error); loadingEl.classList.add('hidden'); noResultsEl.classList.remove('hidden'); const msg = error.userFacing ? error.message : '發生錯誤,無法取得資料。'; noResultsEl.innerHTML = `

${escapeHtml(msg)}

`; } } function renderSearchSummary(data, query, searchType, matchMode, caseSensitive = false) { searchStatsSummary.innerHTML = ''; searchStatsSummary.classList.remove('hidden'); let collocationsHtml = ''; if (data.collocations && data.collocations.length > 0) { const badgesHtml = data.collocations.map(word => `${escapeHtml(word)}` ).join(''); collocationsHtml = `
常用共現關聯詞:
${badgesHtml}
`; } let positionsHtml = ''; if (data.positions) { const total = data.positions.initial + data.positions.medial + data.positions.final; if (total > 0) { const pInit = Math.round((data.positions.initial / total) * 100); const pMed = Math.round((data.positions.medial / total) * 100); const pFin = Math.round((data.positions.final / total) * 100); positionsHtml = `
句首 (Initial)
${data.positions.initial} 次
${pInit}%
句中 (Medial)
${data.positions.medial} 次
${pMed}%
句尾 (Final)
${data.positions.final} 次
${pFin}%
`; } } const caseHint = caseSensitive ? '(區分大小寫)' : ''; let modeLabel = '部分搜尋'; if (matchMode === 'exact') modeLabel = '精準全詞'; else if (matchMode === 'prefix') modeLabel = '詞頭前綴'; else if (matchMode === 'infix') modeLabel = '詞中中綴'; else if (matchMode === 'suffix') modeLabel = '詞尾後綴'; let summaryText = ''; const safeQuery = escapeHtml(query); if (matchMode === 'partial') { summaryText = `
💡 關鍵字 "${safeQuery}" ${caseHint}在所選語料中部分搜尋共出現 ${data.total_occurrences} 次,其中精準全詞共出現 ${data.exact_occurrences} 次。
`; } else if (matchMode === 'prefix') { summaryText = `
💡 關鍵字 "${safeQuery}" ${caseHint}在所選語料中詞頭前綴搜尋共出現 ${data.total_occurrences} 次,其中精準全詞共出現 ${data.exact_occurrences} 次。
`; } else if (matchMode === 'infix') { summaryText = `
💡 關鍵字 "${safeQuery}" ${caseHint}在所選語料中詞中中綴搜尋共出現 ${data.total_occurrences} 次,其中精準全詞共出現 ${data.exact_occurrences} 次。
`; } else if (matchMode === 'suffix') { summaryText = `
💡 關鍵字 "${safeQuery}" ${caseHint}在所選語料中詞尾後綴搜尋共出現 ${data.total_occurrences} 次,其中精準全詞共出現 ${data.exact_occurrences} 次。
`; } else { summaryText = `
💡 關鍵字 "${safeQuery}" ${caseHint}在所選語料中共精準出現了 ${data.total_occurrences} 次。
`; } if (data.corpus_breakdown) { for (const [corpus, counts] of Object.entries(data.corpus_breakdown)) { summaryText += `💡 其中在 "${escapeHtml(corpus)}" 中共出現 ${counts.total || counts.exact || 0} 次。
`; } for (const [corpus, counts] of Object.entries(data.corpus_breakdown)) { if (['族語E樂園', '語推組織語料'].includes(corpus) && counts.subcategories && Object.keys(counts.subcategories).length > 0) { const subBtnsHtml = Object.entries(counts.subcategories).map(([subcat, count]) => { return `
${escapeHtml(subcat)}
${count} 筆
`; }).join(''); summaryText += `
${subBtnsHtml}
`; } } } summaryText += `
`; let ngramsHtml = ''; if (data.ngrams && (data.ngrams.bigrams?.length > 0 || data.ngrams.trigrams?.length > 0)) { const renderNgram = (ng) => `
${escapeHtml(ng.ngram)}${ng.count}次
`; const bigramsHtml = (data.ngrams.bigrams || []).map(renderNgram).join(''); const trigramsHtml = (data.ngrams.trigrams || []).map(renderNgram).join(''); ngramsHtml = `
連詞分析 (N-grams):
${bigramsHtml ? `
雙詞: ${bigramsHtml}
` : ''} ${trigramsHtml ? `
三詞: ${trigramsHtml}
` : ''}
`; } let morphHtml = ''; if (data.morphology) { const familyBtns = data.morphology.family.map(fw => `` ).join(''); morphHtml = `
✨ 構詞學分析 (Morphology):
推測詞根: ${escapeHtml(data.morphology.root)}
家族詞彙:
${familyBtns}
`; } searchStatsSummary.innerHTML = `

${summaryText}

${morphHtml} ${collocationsHtml} ${ngramsHtml} ${positionsHtml} `; const badges = searchStatsSummary.querySelectorAll('.collocation-badge'); badges.forEach(badge => { badge.addEventListener('click', () => { const word = badge.getAttribute('data-word'); searchInput.value = word; document.querySelector('input[name="searchType"][value="truku"]').checked = true; performSearch(); }); }); // Position Filtering const posBtns = searchStatsSummary.querySelectorAll('.position-btn'); let activePos = null; posBtns.forEach(btn => { btn.addEventListener('click', () => { const pos = btn.getAttribute('data-pos'); if (activePos === pos) { activePos = null; posBtns.forEach(b => { b.style.background = 'rgba(0,0,0,0.2)'; b.style.border = 'none'; }); renderResultsList(data.results, query, searchType, caseSensitive); } else { activePos = pos; posBtns.forEach(b => { b.style.background = 'rgba(0,0,0,0.2)'; b.style.border = 'none'; }); btn.style.background = 'rgba(59, 130, 246, 0.2)'; btn.style.border = '1px solid rgba(59, 130, 246, 0.5)'; const filtered = data.results.filter(r => r.positions && r.positions.includes(pos)); renderResultsList(filtered, query, searchType, caseSensitive); } }); }); // Subcategory Filtering const subcatBtns = searchStatsSummary.querySelectorAll('.subcat-btn'); let activeSubcat = null; subcatBtns.forEach(btn => { btn.addEventListener('click', () => { const subcat = btn.getAttribute('data-subcat'); if (activeSubcat === subcat) { activeSubcat = null; subcatBtns.forEach(b => { b.style.background = 'rgba(251, 146, 60, 0.1)'; b.style.border = '1px solid rgba(251, 146, 60, 0.3)'; }); renderResultsList(data.results, query, searchType, caseSensitive); } else { activeSubcat = subcat; subcatBtns.forEach(b => { b.style.background = 'rgba(0,0,0,0.2)'; b.style.border = '1px solid transparent'; }); btn.style.background = 'rgba(251, 146, 60, 0.3)'; btn.style.border = '1px solid rgba(251, 146, 60, 0.8)'; const filtered = data.results.filter(r => r.detailed_source && r.detailed_source.includes(subcat)); renderResultsList(filtered, query, searchType, caseSensitive); } }); }); } function renderResultsList(results, query, searchType, caseSensitive = false) { resultsList.innerHTML = ''; let displayResults = results; let isTruncated = false; if (results.length > 200) { displayResults = results.slice(0, 200); isTruncated = true; } if (displayResults.length === 0) { resultsList.innerHTML = '
沒有符合此條件的結果。
'; return; } const cleanQ = query.replace(/^[-<*]+|[-<*]+$/g, ''); const flags = caseSensitive ? 'g' : 'gi'; const highlightRegex = cleanQ ? new RegExp(`(${escapeRegExp(cleanQ)})`, flags) : null; displayResults.forEach((item, index) => { const li = document.createElement('div'); li.className = `list-item ${index === 0 ? 'active' : ''}`; const hlTag = ''; const hlTruku = highlightRegex && (searchType === 'truku' || searchType === 'source'); const hlChinese = highlightRegex && searchType === 'chinese'; const displayTruku = highlightEscaped(item.truku_sentence || '', hlTruku ? highlightRegex : null, hlTag); const displayChinese = highlightEscaped(item.chinese_translation || '', hlChinese ? highlightRegex : null, hlTag); let badgeHtml = ''; if (item.match_badge === 'exact' || (item.is_exact && !['prefix','suffix','infix'].includes(item.match_badge))) { badgeHtml = '精準'; } else if (item.match_badge === 'prefix') { badgeHtml = '前綴'; } else if (item.match_badge === 'infix') { badgeHtml = '中綴'; } else if (item.match_badge === 'suffix') { badgeHtml = '後綴'; } else { badgeHtml = '部分'; } let corpusBadge = ''; let subcatBadge = ''; if (item.corpus_source === '族語辭典') { corpusBadge = '辭典'; } else if (item.corpus_source === '族語E樂園') { corpusBadge = 'E樂園'; if (item.detailed_source && item.detailed_source.trim() !== '') { subcatBadge = `${escapeHtml(item.detailed_source.replace(/^族語E樂園 - /, ''))}`; } } else if (item.corpus_source === '族語文學') { corpusBadge = '文學'; } else if (item.corpus_source === '語推組織語料') { corpusBadge = '語推'; if (item.detailed_source && item.detailed_source.trim() !== '') { subcatBadge = `${escapeHtml(item.detailed_source)}`; } } else if (item.corpus_source === '族語資料庫語料') { corpusBadge = '資料庫'; } else if (item.corpus_source) { corpusBadge = `${escapeHtml(item.corpus_source)}`; } li.innerHTML = `
${corpusBadge} ${subcatBadge} ${displayTruku} ${badgeHtml}
${displayChinese}
`; li.addEventListener('click', () => { document.querySelectorAll('.list-item').forEach(el => el.classList.remove('active')); li.classList.add('active'); renderDetailCard(item); }); resultsList.appendChild(li); }); if (isTruncated) { const truncRow = document.createElement('div'); truncRow.style.padding = '1rem'; truncRow.style.textAlign = 'center'; truncRow.style.color = '#94a3b8'; truncRow.style.fontSize = '0.9rem'; truncRow.innerHTML = `已隱藏其餘 ${results.length - 200} 筆結果。請縮小搜尋範圍或點擊「匯出 CSV」檢視全部資料。`; resultsList.appendChild(truncRow); } if (displayResults.length > 0) { renderDetailCard(displayResults[0]); } } function renderDetailCard(item) { emptyDetailState.style.display = 'none'; resultDetailCard.classList.remove('hidden'); const highlightRegex = currentQuery ? new RegExp(`(${escapeRegExp(currentQuery)})`, 'gi') : null; const hlTag = ''; const isTruku = currentSearchType === 'truku'; const isChinese = currentSearchType === 'chinese'; const isSource = currentSearchType === 'source'; const kwicText = highlightEscaped(item.kwic_sentence || '', isTruku ? highlightRegex : null, ''); const displayTruku = highlightEscaped(item.truku_sentence || '', (isTruku || isSource) ? highlightRegex : null, hlTag); const displayChinese = highlightEscaped(item.chinese_translation || '', isChinese ? highlightRegex : null, hlTag); const displaySource = highlightEscaped(item.source_word || '', isSource ? highlightRegex : null, hlTag); const kwicBlock = isTruku ? `
搭配詞: ${kwicText}
` : `
${displayTruku}
`; const srcWord = item.source_word || ''; const srcTruku = item.truku_sentence || ''; const srcChinese = item.chinese_translation || ''; resultDetailCard.innerHTML = `

${displaySource || '詳細資訊'}

${item.is_exact ? '精準匹配' : '部分匹配'}
回報此句
${item.source_word ? ` ` : ''}
${kwicBlock} ${isTruku ? `
完整例句: ${displayTruku}
` : ''}
${displayChinese}
${item.full_explanation ? `
${escapeHtml(item.full_explanation).replace(/;/g, '
')}
` : ''}
`; } // ── TTS 語音播放函式 ── window.playTTS = async function(type, text, btnId) { const btn = document.getElementById(btnId); const statusEl = document.getElementById('tts-status'); const audioEl = document.getElementById('tts-audio-player'); const speakerSelect = document.getElementById('tts-speaker-select'); if (!btn || !statusEl || !audioEl) return; const speaker = speakerSelect ? speakerSelect.value : 'female'; const originalHTML = btn.innerHTML; btn.disabled = true; btn.innerHTML = '⏳ 生成中...'; statusEl.textContent = '正在呼叫原語會 TTS,請稍候(約 3~6 秒)...'; statusEl.style.color = 'var(--text-muted)'; try { const params = new URLSearchParams({ text, speaker }); const resp = await fetch(`/api/tts?${params.toString()}`); if (!resp.ok) { const err = await resp.json(); throw new Error(err.error || 'TTS 服務異常'); } const blob = await resp.blob(); const url = URL.createObjectURL(blob); audioEl.src = url; audioEl.style.display = 'block'; audioEl.play(); const cacheHit = resp.headers.get('X-Cache') === 'HIT'; statusEl.textContent = cacheHit ? '✅ 快取命中,立即播放!' : '✅ 語音生成完畢,正在播放...'; statusEl.style.color = '#34d399'; // ── 記錄 TTS 事件 ── logEvent('tts', { type, speaker, cache_hit: cacheHit, text_length: text.length }); audioEl.onended = () => { statusEl.textContent = ''; }; } catch (e) { statusEl.textContent = `❌ ${e.message}`; statusEl.style.color = '#f87171'; } finally { btn.disabled = false; btn.innerHTML = originalHTML; } }; function escapeRegExp(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } // --- IGT Glosser Integration --- // Calls truku-glosser backend (port 8000) and renders 4-line IGT format // --- Stats Logic --- async function loadStats() { statsLoading.classList.remove('hidden'); try { const response = await fetch('/api/stats'); if (!response.ok) throw new Error('Network response was not ok'); const data = await response.json(); statsLoading.classList.add('hidden'); statsDashboard.classList.remove('hidden'); document.getElementById('stat-entries').innerText = data.total_entries.toLocaleString(); document.getElementById('stat-sentences').innerText = data.total_sentences.toLocaleString(); document.getElementById('stat-avg-len').innerText = data.avg_sentence_length.toLocaleString(); initSubTabs(); renderCharts(data); statsLoaded = true; } catch (error) { console.error('Error fetching stats:', error); statsLoading.innerHTML = `

載入統計資料失敗。

`; } } function initSubTabs() { const subTabBtns = document.querySelectorAll('.sub-tab-btn'); const subTabContents = document.querySelectorAll('.sub-tab-content'); subTabBtns.forEach(btn => { btn.addEventListener('click', () => { subTabBtns.forEach(b => b.classList.remove('active')); subTabContents.forEach(c => c.classList.add('hidden')); btn.classList.add('active'); document.getElementById(btn.dataset.subtab).classList.remove('hidden'); // Force charts to resize when their container becomes visible setTimeout(() => { if (wordChart) wordChart.resize(); if (letterChart) letterChart.resize(); if (prefixChart) prefixChart.resize(); if (suffixChart) suffixChart.resize(); }, 10); }); }); } function renderCharts(data) { Chart.defaults.color = '#94a3b8'; Chart.defaults.borderColor = 'rgba(255, 255, 255, 0.1)'; if (typeof ChartDataLabels !== 'undefined') { Chart.register(ChartDataLabels); } // 1. Corpus Pie Chart const pieCtx = document.getElementById('corpusPieChart').getContext('2d'); const corpusKeys = Object.keys(data.corpus_distribution); const corpusValues = corpusKeys.map(k => data.corpus_distribution[k]); const corpusColors = corpusKeys.map(k => { if (k === '族語辭典') return '#60a5fa'; if (k === '族語E樂園') return '#a855f7'; if (k === '族語文學') return '#10b981'; return '#f59e0b'; }); if (corpusPieChart) corpusPieChart.destroy(); corpusPieChart = new Chart(pieCtx, { type: 'doughnut', data: { labels: corpusKeys, datasets: [{ data: corpusValues, backgroundColor: corpusColors, borderWidth: 0 }] }, options: { responsive: true, plugins: { legend: { position: 'bottom', labels: { color: '#e2e8f0' } }, datalabels: { color: '#fff', font: { weight: 'bold' }, formatter: (value, ctx) => { let sum = 0; let dataArr = ctx.chart.data.datasets[0].data; dataArr.map(data => { sum += data; }); let percentage = (value*100 / sum).toFixed(1)+"%"; return percentage; } } } } }); // 2. Letter Chart const letterCtx = document.getElementById('letterChart').getContext('2d'); const letterKeys = Object.keys(data.first_letters).sort(); const letterValues = letterKeys.map(k => data.first_letters[k]); const bgColors = letterKeys.map((_, i) => `hsla(${i * (360/letterKeys.length)}, 70%, 60%, 0.6)`); const borderColors = letterKeys.map((_, i) => `hsla(${i * (360/letterKeys.length)}, 70%, 60%, 1)`); if (letterChart) letterChart.destroy(); letterChart = new Chart(letterCtx, { type: 'bar', data: { labels: letterKeys.map(k => k.toUpperCase()), datasets: [{ label: '詞條數量', data: letterValues, backgroundColor: bgColors, borderColor: borderColors, borderWidth: 1 }] }, options: { responsive: true, plugins: { legend: { display: false }, datalabels: { color: '#e2e8f0', anchor: 'end', align: 'top', formatter: Math.round, font: { weight: 'bold' } } } } }); // Prefix Chart if (data.prefixes) { const preCtx = document.getElementById('prefixChart').getContext('2d'); const preKeys = Object.keys(data.prefixes); const preVals = preKeys.map(k => data.prefixes[k]); if (prefixChart) prefixChart.destroy(); prefixChart = new Chart(preCtx, { type: 'bar', data: { labels: preKeys, datasets: [{ data: preVals, backgroundColor: 'rgba(236, 72, 153, 0.6)', borderColor: 'rgba(236, 72, 153, 1)', borderWidth: 1 }] }, options: { responsive: true, plugins: { legend: { display: false }, datalabels: { color: '#e2e8f0', anchor: 'end', align: 'top', font: { weight: 'bold' } } } } }); } // Suffix Chart if (data.suffixes) { const sufCtx = document.getElementById('suffixChart').getContext('2d'); const sufKeys = Object.keys(data.suffixes); const sufVals = sufKeys.map(k => data.suffixes[k]); if (suffixChart) suffixChart.destroy(); suffixChart = new Chart(sufCtx, { type: 'bar', data: { labels: sufKeys, datasets: [{ data: sufVals, backgroundColor: 'rgba(16, 185, 129, 0.6)', borderColor: 'rgba(16, 185, 129, 1)', borderWidth: 1 }] }, options: { responsive: true, plugins: { legend: { display: false }, datalabels: { color: '#e2e8f0', anchor: 'end', align: 'top', font: { weight: 'bold' } } } } }); } // 3. Top Words const top50 = data.top_words; const topWordsKeys = top50.map(item => item.word); const topWordsValues = top50.map(item => item.count); if (wordChart) wordChart.destroy(); const wordCtx = document.getElementById('wordChart').getContext('2d'); wordChart = new Chart(wordCtx, { type: 'bar', data: { labels: topWordsKeys, datasets: [{ label: '出現次數', data: topWordsValues, backgroundColor: '#3b82f6', borderRadius: 4 }] }, options: { indexAxis: 'y', responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, datalabels: { color: '#e2e8f0', anchor: 'end', align: 'right', formatter: Math.round, font: { weight: 'bold', size: 10 } } }, scales: { x: { grid: { color: 'rgba(255,255,255,0.05)' }, max: Math.max(...topWordsValues) * 1.15 }, y: { grid: { display: false } } } } }); // Populate Left List const listContainer = document.getElementById('stats-word-list'); listContainer.innerHTML = top50.map(item => `
${escapeHtml(item.word)} ${item.translation ? `(${escapeHtml(item.translation)})` : ''}
${item.count.toLocaleString()}
`).join(''); // Populate N-grams const bigramsContainer = document.getElementById('stats-bigrams'); bigramsContainer.innerHTML = Object.entries(data.top_bigrams).sort((a,b)=>b[1]-a[1]).map(item => `
${escapeHtml(item[0])} ${item[1].toLocaleString()}次
`).join(''); const trigramsContainer = document.getElementById('stats-trigrams'); trigramsContainer.innerHTML = Object.entries(data.top_trigrams).sort((a,b)=>b[1]-a[1]).map(item => `
${escapeHtml(item[0])} ${item[1].toLocaleString()}次
`).join(''); // Populate Corpus Compare Table if (data.top_words_by_corpus) { const tableBody = document.querySelector('#corpus-compare-table tbody'); const dictTop = data.top_words_by_corpus['族語辭典'] || []; const klokahTop = data.top_words_by_corpus['族語E樂園'] || []; const litTop = data.top_words_by_corpus['族語文學'] || []; let tableHtml = ''; for (let i = 0; i < 50; i++) { const dictItem = dictTop[i] || {word: '-', count: 0, translation: ''}; const klokahItem = klokahTop[i] || {word: '-', count: 0, translation: ''}; const litItem = litTop[i] || {word: '-', count: 0, translation: ''}; tableHtml += ` ${i + 1} ${escapeHtml(dictItem.word)} ${dictItem.word !== '-' ? `(${dictItem.count})${dictItem.percentage}%` : ''} ${dictItem.translation ? `
${escapeHtml(dictItem.translation)}
` : ''} ${escapeHtml(klokahItem.word)} ${klokahItem.word !== '-' ? `(${klokahItem.count})${klokahItem.percentage}%` : ''} ${klokahItem.translation ? `
${escapeHtml(klokahItem.translation)}
` : ''} ${escapeHtml(litItem.word)} ${litItem.word !== '-' ? `(${litItem.count})${litItem.percentage}%` : ''} ${litItem.translation ? `
${escapeHtml(litItem.translation)}
` : ''} `; } if (tableBody) tableBody.innerHTML = tableHtml; } } }); window.openReportModal = function(type, id = null, sentence = '') { const modal = document.getElementById('reportModal'); const typeSelect = document.getElementById('reportType'); const sentenceGroup = document.getElementById('reportSentenceGroup'); const sentenceArea = document.getElementById('reportSentence'); const idInput = document.getElementById('reportSentenceId'); typeSelect.value = type; document.getElementById('reportDescription').value = ''; document.getElementById('reportMessage').innerHTML = ''; if (type === 'sentence_error' && id) { sentenceGroup.style.display = 'block'; sentenceArea.value = sentence; idInput.value = id; } else { sentenceGroup.style.display = 'none'; sentenceArea.value = ''; idInput.value = ''; } modal.classList.remove('hidden'); } window.closeReportModal = function() { document.getElementById('reportModal').classList.add('hidden'); } window.submitReport = async function() { const type = document.getElementById('reportType').value; const description = document.getElementById('reportDescription').value; const email = document.getElementById('reportEmail').value; const sentenceId = document.getElementById('reportSentenceId').value; const sentence = document.getElementById('reportSentence').value; if (!description.trim()) { alert('請填寫詳細說明!'); return; } const btn = document.getElementById('submitReportBtn'); const msg = document.getElementById('reportMessage'); btn.disabled = true; btn.innerText = '傳送中...'; msg.innerHTML = ''; try { const res = await fetch('/api/report', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type, description, email, sentenceId, sentence }) }); const data = await res.json(); if (res.ok) { msg.style.color = '#10b981'; msg.innerHTML = '✅ 回報已成功送出給 lowking@ilrdf.org.tw!謝謝您!'; setTimeout(() => closeReportModal(), 2000); } else { throw new Error(data.message || data.error || 'Server error'); } } catch (error) { msg.style.color = '#ef4444'; msg.textContent = `❌ 傳送失敗:${error.message}`; } finally { btn.disabled = false; btn.innerText = '送出回報'; } } // Global variables to store current analyzed words for export let currentAiWord1 = ''; let currentAiWord2 = ''; // ========================================================= // 🔤 語意標注 / 🔬 構詞語法標注 模式切換(批次標注面板專用) // 'grammar' 為既有預設模式(含焦點/時貌/格位等語言學術語), // 'semantic' 為新增模式(每個詞只給白話中文意思,適合初學族人) // ========================================================= let glosserMode = 'grammar'; window.setGlosserMode = function(mode) { glosserMode = mode; const semBtn = document.getElementById('glosser-mode-semantic'); const gramBtn = document.getElementById('glosser-mode-grammar'); if (!semBtn || !gramBtn) return; if (mode === 'semantic') { semBtn.style.background = '#7c3aed'; semBtn.style.color = 'white'; gramBtn.style.background = 'transparent'; gramBtn.style.color = '#94a3b8'; } else { gramBtn.style.background = '#7c3aed'; gramBtn.style.color = 'white'; semBtn.style.background = 'transparent'; semBtn.style.color = '#94a3b8'; } }; // ========================================================= // 🏷️ IGT Glosser Integration — triggerGlosser() // Calls truku-glosser backend on port 8000, renders 4-line IGT // ========================================================= window.triggerGlosser = async function(btn, trukuSentence, chineseTranslation) { const outputArea = btn.closest('.card-body').querySelector('.igt-output-area'); // Toggle: if already showing, hide it if (outputArea.style.display !== 'none' && outputArea.innerHTML !== '') { outputArea.style.display = 'none'; btn.innerHTML = '🏷️ 語言學標注 (IGT)'; return; } btn.innerHTML = '⏳ 分析中...'; btn.disabled = true; outputArea.style.display = 'block'; outputArea.innerHTML = `
正在呼叫語言學 AI 標注引擎...
`; try { const customApiKey = localStorage.getItem('custom_gemini_api_key') || ""; const requestMode = glosserMode; const response = await fetch('/api/parse', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ truku_sentence: trukuSentence, chinese_translation: chineseTranslation, custom_api_key: customApiKey, annotation_mode: requestMode }) }); if (response.status === 429) { const errData = await response.json().catch(() => ({})); throw new Error(errData.detail === 'RATE_LIMITED' ? errData.message : 'QUOTA_EXCEEDED'); } if (response.status === 503) { const errData = await response.json().catch(() => ({})); throw new Error(errData.message || 'SERVICE_BUSY'); } if (!response.ok) { let errText = await response.text(); try { const parsed = JSON.parse(errText); if (parsed.message) errText = parsed.message; else if (parsed.detail) errText = parsed.detail; } catch(ign) {} throw new Error(errText); } const data = await response.json(); const sentences = data.sentences || []; if (sentences.length === 0) { outputArea.innerHTML = `
標注引擎未回傳結果。
`; return; } // Build 4-line IGT HTML const usedMode = data.annotation_mode || requestMode; const modeLabel = usedMode === 'semantic' ? '🔤 語意標注' : '🔬 構詞語法標注'; let igtHtml = `
`; sentences.forEach((sentObj, sIdx) => { const words = sentObj.words || []; const translation = sentObj.translation || chineseTranslation || ''; // Build the raw sentence (Line 1) from words const originalLine = words.map(w => w.raw).join(' '); // Word columns for lines 2 & 3 const wordColsHtml = words.map((w, i) => { const isLow = w.confidence === 'low'; return `
${escapeHtml(w.morph || w.raw || '')}
${escapeHtml(w.gloss || '')}
`; }).join(''); igtHtml += `
${modeLabel}
${escapeHtml(originalLine)}
${wordColsHtml}
${escapeHtml(translation)}
`; }); igtHtml += `
`; outputArea.innerHTML = igtHtml; // Store raw data on the container for download functions outputArea._glosserData = sentences; // ── 記錄 IGT 標注事件 ── logEvent('igt_parse', { sentence_length: trukuSentence.length, word_count: sentences.reduce((a, s) => a + (s.words || []).length, 0) }); btn.innerHTML = '🏷️ 收起標注'; } catch (e) { let msg = e.message; if (msg.includes('SERVICE_BUSY') || msg.includes('503') || msg.includes('high demand') || msg.includes('UNAVAILABLE')) { msg = `Google 伺服器目前尖峰塞車 (503 High Demand),請稍候 10~20 秒後再試一次;若需要極高穩定度可至「⚙️ API 設定」填寫付費版金鑰。`; } else if (msg.includes('QUOTA_EXCEEDED') || msg.includes('429')) { msg = `API 額度已用完,請至右上角「⚙️ API 設定」填寫您的 API 金鑰。`; } outputArea.innerHTML = `
❌ 標注失敗:${escapeHtml(msg)}
`; btn.innerHTML = '🏷️ 語言學標注 (IGT)'; } finally { btn.disabled = false; } }; // 把整句加入主辭典:管理員直接寫入;一般使用者改為送出建議,等管理員審核 async function addSentenceToDictionary(trukuSentence, chineseTranslation) { if (!isAdmin) { const note = prompt('(選填)想補充給審核者的說明:') || ''; await submitSuggestion({ kind: 'sentence', truku_sentence: trukuSentence, chinese_translation: chineseTranslation, note }); return '💡 已送出建議,管理員審核通過後才會加入主辭典。'; } const res = await adminFetch('/api/dictionary/add_sentence', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ truku_sentence: trukuSentence, chinese_translation: chineseTranslation }) }); if (!res.ok) throw new Error(await readErrorMessage(res, '請稍後再試。')); const data = await res.json().catch(() => ({})); return data.status === 'exists' ? 'ℹ️ 此句與中文翻譯已在主辭典中,未重複新增。' : '✅ 已加入主辭典!'; } // 若整句不在主辭典,詢問是否一併新增(或一併建議新增) async function offerAddToDictionary(block) { try { const trukuSentence = block.querySelector('.igt-l1').innerText.trim(); const chineseTranslation = block.querySelector('.igt-l4').innerText.trim(); const checkRes = await fetch('/api/dictionary/check_sentence', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ truku_sentence: trukuSentence }) }); if (!checkRes.ok) return; const checkData = await checkRes.json(); if (checkData.exists) return; const action = isAdmin ? '新增到主辭典' : '建議把它加入主辭典'; if (!confirm(`發現這整句例句目前不在主辭典中:\n\n${trukuSentence}\n\n請問要順便${action}嗎?`)) return; alert(await addSentenceToDictionary(trukuSentence, chineseTranslation)); } catch(e) { alert(`❌ 送出失敗:${e.message}`); } } // 逐詞校正:管理員直接存進黃金記憶庫;一般使用者送出建議(依區塊標注模式分開存放) async function saveIgtBlockCorrection(block) { const question = isAdmin ? "確定要將這些單字校正儲存至記憶庫嗎?\n這將做為未來 AI 自動標注的黃金標準!" : "確定要送出這些單字校正建議嗎?\n管理員審核通過後,才會成為 AI 自動標注的黃金標準。"; if (!confirm(question)) return; const saveMode = block.dataset.mode || glosserMode; const words = Array.from(block.querySelectorAll('.igt-word-col')).map(col => ({ raw: col.dataset.raw, morph: col.querySelector('.igt-l2').innerText.trim(), gloss: col.querySelector('.igt-l3').innerText.trim() })); try { if (isAdmin) { const res = await adminFetch('/api/save_correct', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ words, annotation_mode: saveMode }) }); if (!res.ok) throw new Error(await readErrorMessage(res, '請稍後再試。')); } else { const note = prompt('(選填)想補充給審核者的說明:') || ''; await submitSuggestion({ kind: 'correction', words, annotation_mode: saveMode, note, truku_sentence: block.querySelector('.igt-l1').innerText.trim(), chinese_translation: block.querySelector('.igt-l4').innerText.trim() }); } } catch(e) { alert(`❌ ${isAdmin ? '儲存' : '送出'}失敗:${e.message}`); return; } const msgEl = block.querySelector('.igt-save-msg'); if (msgEl) { msgEl.textContent = isAdmin ? '✅ 已儲存!' : '✅ 已送出建議!'; msgEl.style.display = 'inline'; setTimeout(() => { msgEl.style.display = 'none'; }, 3000); } alert(isAdmin ? "✅ 儲存成功!" : "💡 已送出建議,管理員審核通過後才會寫入記憶庫。"); await offerAddToDictionary(block); } window.igtSave = function(btn, sIdx) { return saveIgtBlockCorrection(btn.closest('.igt-block')); }; window.igtDownloadCsv = function(btn) { const outputArea = btn.closest('.igt-output-area'); const sentences = outputArea._glosserData || []; let csv = '\uFEFF句子編號,行一(原文),行二(詞素切分),行三(語法標記),行四(華語翻譯)\n'; sentences.forEach((s, i) => { const blocks = btn.closest('.igt-container').querySelectorAll('.igt-block'); const block = blocks[i]; const cols = block ? block.querySelectorAll('.igt-word-col') : []; const raws = s.words.map(w => w.raw).join(' '); const morphs = Array.from(cols).map(c => c.querySelector('.igt-l2').innerText.trim()).join(' '); const glosses = Array.from(cols).map(c => c.querySelector('.igt-l3').innerText.trim()).join(' '); const trans = s.translation || ''; csv += `${i+1},"${raws.replace(/"/g,'""')}","${morphs.replace(/"/g,'""')}","${glosses.replace(/"/g,'""')}","${trans.replace(/"/g,'""')}"\n`; }); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `igt_annotation_${new Date().toISOString().slice(0,10)}.csv`; a.click(); }; window.igtDownloadWord = async function(btn, sIdx) { const outputArea = btn.closest('.igt-output-area'); const sentences = outputArea._glosserData || []; const blocks = btn.closest('.igt-container').querySelectorAll('.igt-block'); // Build up-to-date data from editable fields const payload = { sentences: [] }; blocks.forEach((block, i) => { const cols = block.querySelectorAll('.igt-word-col'); const words = Array.from(cols).map(col => ({ raw: col.dataset.raw, morph: col.querySelector('.igt-l2').innerText.trim(), gloss: col.querySelector('.igt-l3').innerText.trim(), confidence: 'high' })); const trans = block.querySelector('.igt-l4').innerText.replace(/^'|'$/g, ''); payload.sentences.push({ words, translation: trans }); }); try { const res = await fetch('/api/download_word', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); const blob = await res.blob(); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = '太魯閣語IGT標注成果.docx'; a.click(); } catch(e) { alert('下載失敗,請確認標注後端已開啟。'); } }; // AI Analysis Logic document.addEventListener('DOMContentLoaded', () => { const analyzeBtn = document.getElementById('aiAnalyzeBtn'); if (analyzeBtn) { analyzeBtn.addEventListener('click', async () => { const w1 = document.getElementById('aiWord1').value.trim(); const w2 = document.getElementById('aiWord2').value.trim(); const limit = document.getElementById('aiSentenceLimit').value; currentAiWord1 = w1; currentAiWord2 = w2; if (!w1) { alert('請至少輸入第一個詞彙進行分析!'); return; } const loading = document.getElementById('ai-loading'); const errorDiv = document.getElementById('ai-error'); const resultContainer = document.getElementById('ai-result-container'); const contentDiv = document.getElementById('ai-markdown-content'); const statsInfo = document.getElementById('ai-stats-info'); loading.classList.remove('hidden'); errorDiv.classList.add('hidden'); resultContainer.classList.add('hidden'); analyzeBtn.disabled = true; try { const params = new URLSearchParams({ w1, limit }); if (w2) params.append('w2', w2); // 自訂金鑰放在請求標頭,不放進網址 const customApiKey = localStorage.getItem('custom_gemini_api_key') || ""; const headers = customApiKey ? { 'X-Gemini-Api-Key': customApiKey } : {}; const response = await fetch(`/api/ai_analysis?${params.toString()}`, { headers }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || '分析發生錯誤'); } if (w2) { statsInfo.innerHTML = `基於 ${Number(data.w1_count) || 0} 句 ${escapeHtml(w1)} 與 ${Number(data.w2_count) || 0} 句 ${escapeHtml(w2)} 例句進行分析`; } else { statsInfo.innerHTML = `基於 ${Number(data.w1_count) || 0} 句 ${escapeHtml(w1)} 例句進行分析`; } // Parse markdown first, then strip anything executable before inserting let htmlContent = sanitizeHtml(marked.parse(data.result || '')); function escapeRegExp(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } // Highlight keywords in HTML (only outside tags) if (w1) { const regex1 = new RegExp('\\b(' + escapeRegExp(w1) + ')\\b(?![^<]*>)', 'gi'); htmlContent = htmlContent.replace(regex1, '$1'); } if (w2) { const regex2 = new RegExp('\\b(' + escapeRegExp(w2) + ')\\b(?![^<]*>)', 'gi'); htmlContent = htmlContent.replace(regex2, '$1'); } contentDiv.innerHTML = htmlContent; resultContainer.classList.remove('hidden'); } catch (err) { errorDiv.textContent = err.message; errorDiv.classList.remove('hidden'); } finally { loading.classList.add('hidden'); analyzeBtn.disabled = false; } }); } }); // AI Analysis Export Functions function exportToDoc() { const contentHtml = document.getElementById('ai-markdown-content').innerHTML; const header = `太魯閣語語言學分析報告`; const footer = ``; const sourceHTML = header + contentHtml + footer; let filename = '太魯閣語語言學分析報告_' + currentAiWord1; if (currentAiWord2) filename += '&' + currentAiWord2; filename += '.doc'; const source = 'data:application/vnd.ms-word;charset=utf-8,' + encodeURIComponent(sourceHTML); const fileDownload = document.createElement("a"); document.body.appendChild(fileDownload); fileDownload.href = source; fileDownload.download = filename; fileDownload.click(); document.body.removeChild(fileDownload); } function exportToPdf() { const element = document.getElementById('ai-markdown-content'); // Temporarily set text color to black for PDF rendering const originalColor = element.style.color; element.style.color = '#000000'; // Add a class to adjust typography for print element.classList.add('pdf-exporting'); let filename = '太魯閣語語言學分析報告_' + currentAiWord1; if (currentAiWord2) filename += '&' + currentAiWord2; filename += '.pdf'; const opt = { margin: 0.6, filename: filename, image: { type: 'jpeg', quality: 1.0 }, html2canvas: { scale: 4, useCORS: true, logging: false }, jsPDF: { unit: 'in', format: 'a4', orientation: 'portrait' }, pagebreak: { mode: ['css', 'avoid-all'] } }; html2pdf().set(opt).from(element).save().then(() => { // Revert text color back to original after PDF is generated element.style.color = originalColor; element.classList.remove('pdf-exporting'); }); } // ========================================================= // 🏷️ 語料標注頁籤 (Tab Glosser) — Full standalone logic // ========================================================= let glosserSessionData = []; // Stores all sentences from last batch run // -- 1. Status Checker: polls server every 5s to show green/red dot -- async function checkGlosserStatus() { const dot = document.getElementById('glosser-status-dot'); const text = document.getElementById('glosser-status-text'); if (!dot || !text) return; try { const res = await fetch('/', { signal: AbortSignal.timeout(2000) }); if (res.ok) { dot.style.background = '#4ade80'; text.style.color = '#4ade80'; text.textContent = '標注引擎就緒 (雲端連線正常)'; } else { throw new Error(); } } catch { dot.style.background = '#f87171'; text.style.color = '#f87171'; text.textContent = '標注引擎離線'; } } // -- 2. Render IGT 4-line block into #glosser-results -- function renderGlosserIGT(sentences, mode) { const usedMode = mode || glosserMode; const modeBadge = usedMode === 'semantic' ? `🔤 語意標注` : `🔬 構詞語法標注`; const container = document.getElementById('glosser-results'); container.innerHTML = ''; glosserSessionData = sentences; sentences.forEach((sentObj, sIdx) => { const words = sentObj.words || []; const translation = sentObj.translation || ''; const originalLine = words.map(w => w.raw).join(' '); let groups = []; words.forEach((w, wIdx) => { const m = (w.morph || w.raw || '').trim(); if (m.startsWith('=') && groups.length > 0) { groups[groups.length - 1].push({w, wIdx}); } else { groups.push([{w, wIdx}]); } }); const wordColsHtml = groups.map(group => { const cols = group.map(item => { const w = item.w; const wIdx = item.wIdx; const isLow = w.confidence === 'low'; return `
${escapeHtml(w.morph || w.raw || '')}
${escapeHtml(w.gloss || '')}
`; }).join(''); return `
${cols}
`; }).join(''); const block = document.createElement('div'); block.className = 'igt-block'; block.dataset.sidx = sIdx; block.dataset.mode = usedMode; block.innerHTML = `
句子成果 #${sIdx + 1}${modeBadge}
${escapeHtml(originalLine)}
${wordColsHtml}
${escapeHtml(translation)}
`; container.appendChild(block); }); } // -- 3. Analyze button handler -- document.addEventListener('DOMContentLoaded', () => { // Start status polling as soon as page loads checkGlosserStatus(); setInterval(checkGlosserStatus, 6000); const btn = document.getElementById('glosserAnalyzeBtn'); if (!btn) return; btn.addEventListener('click', async () => { let trukuText = (document.getElementById('glosser-truku-input').value || '').trim(); let chineseText = (document.getElementById('glosser-chinese-input').value || '').trim(); if (!trukuText) { alert('請先輸入太魯閣語語料!'); return; } const loading = document.getElementById('glosser-loading'); const errorDiv = document.getElementById('glosser-error'); const timer = document.getElementById('glosser-timer'); const results = document.getElementById('glosser-results'); const batchBar = document.getElementById('glosser-batch-bar'); loading.classList.remove('hidden'); errorDiv.classList.add('hidden'); timer.classList.add('hidden'); results.innerHTML = ''; batchBar.classList.add('hidden'); btn.disabled = true; // Smart sentence splitting (same as original glosser) trukuText = trukuText.replace(/([.?])\s+/g, '$1\n').replace(/\n+/g, '\n'); chineseText = chineseText.replace(/([。?!])\s*/g, '$1\n').replace(/\n+/g, '\n'); const startTime = performance.now(); try { const customApiKey = localStorage.getItem('custom_gemini_api_key') || ""; const res = await fetch('/api/parse', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ truku_sentence: trukuText, chinese_translation: chineseText, custom_api_key: customApiKey, annotation_mode: glosserMode }) }); if (res.status === 429) { const errData = await res.json().catch(() => ({})); throw new Error(errData.detail === 'RATE_LIMITED' ? errData.message : 'QUOTA_EXCEEDED'); } if (res.status === 503) { const errData = await res.json().catch(() => ({})); throw new Error(errData.message || 'SERVICE_BUSY'); } if (!res.ok) { let errText = await res.text(); try { const parsed = JSON.parse(errText); if (parsed.message) errText = parsed.message; else if (parsed.detail) errText = parsed.detail; } catch(ign) {} throw new Error(errText); } const data = await res.json(); const elapsed = ((performance.now() - startTime) / 1000).toFixed(2); const sentences = data.sentences || []; if (sentences.length === 0) throw new Error('AI 未回傳任何標注結果。'); const usedMode = data.annotation_mode || glosserMode; const modeLabel = usedMode === 'semantic' ? '🔤 語意標注' : '🔬 構詞語法標注'; timer.classList.remove('hidden'); timer.innerHTML = `⏱️ 批次標注完成(${modeLabel})!共處理 ${sentences.length} 個句子,核心運算耗時 ${elapsed} 秒。`; renderGlosserIGT(sentences, usedMode); batchBar.classList.remove('hidden'); } catch(e) { errorDiv.classList.remove('hidden'); if (e.message.includes('QUOTA_EXCEEDED')) { errorDiv.innerHTML = `❌ 系統公用額度已用完!
請通知作者,或點擊右上角「⚙️ API 設定」填寫您個人的 Google Gemini API Key 後繼續使用。`; } else if (e.message.includes('SERVICE_BUSY') || e.message.includes('503') || e.message.includes('high demand') || e.message.includes('UNAVAILABLE')) { errorDiv.innerHTML = `⚠️ Google 伺服器目前尖峰塞車 (503 High Demand)
這是 Google 官方端暫時性算力滿載(免費版 Key 優先級較低時常發生)。
👉 請稍等 10~20 秒後重新點擊「送出標注」;若需大量穩定標注,更換為付費版 (Pay-as-you-go) 金鑰可徹底避免此問題。
`; } else { errorDiv.innerHTML = `❌ 標注失敗:${escapeHtml(e.message)}
請檢查您的 API Key 是否有效,或稍後再試。`; } } finally { loading.classList.add('hidden'); btn.disabled = false; } }); }); // -- 4. Save one sentence to memory DB -- window.glosserSaveSentence = function(btn, sIdx) { return saveIgtBlockCorrection(btn.closest('.igt-block')); }; window.importToDictSentence = async function(btn) { const question = isAdmin ? "確定要將這句話與意譯「匯入主辭典」作為標準例句嗎?" : "確定要建議把這句話與意譯加入主辭典嗎?\n管理員審核通過後才會加入。"; if (!confirm(question)) return; const block = btn.closest('.igt-block'); const trukuSentence = block.querySelector('.igt-l1').innerText.trim(); const chineseTranslation = block.querySelector('.igt-l4').innerText.trim(); btn.textContent = isAdmin ? '匯入中...' : '送出中...'; btn.disabled = true; try { const message = await addSentenceToDictionary(trukuSentence, chineseTranslation); btn.textContent = isAdmin ? '✅ 完成' : '✅ 已送出建議'; alert(message); } catch(e) { btn.textContent = '❌ 失敗'; alert(`❌ ${isAdmin ? '匯入' : '送出'}失敗:${e.message}`); } setTimeout(() => { btn.textContent = igtImportLabel(); btn.disabled = false; }, 3000); }; // -- 5. Batch CSV export from the glosser tab -- window.glosserBatchCsv = function() { const blocks = document.querySelectorAll('#glosser-results .igt-block'); if (!blocks.length) return; let csv = '\uFEFF句子編號,行一(原文),行二(詞素切分),行三(語法標記),行四(華語翻譯)\n'; blocks.forEach((block, i) => { const l1 = block.querySelector('.igt-l1').innerText.trim(); const cols = block.querySelectorAll('.igt-word-col'); const morphs = Array.from(cols).map(c => c.querySelector('.igt-l2').innerText.trim()).join(' '); const glosses = Array.from(cols).map(c => c.querySelector('.igt-l3').innerText.trim()).join(' '); const l4 = block.querySelector('.igt-l4').innerText.replace(/^'|'$/g,'').trim(); csv += `${i+1},"${l1.replace(/"/g,'""')}","${morphs.replace(/"/g,'""')}","${glosses.replace(/"/g,'""')}","${l4.replace(/"/g,'""')}"\n`; }); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `太魯閣語IGT標注_${new Date().toISOString().slice(0,10)}.csv`; a.click(); }; // -- 6. Batch Word export from the glosser tab -- window.glosserBatchWord = async function() { const blocks = document.querySelectorAll('#glosser-results .igt-block'); if (!blocks.length) return; const payload = { sentences: [] }; blocks.forEach(block => { const cols = block.querySelectorAll('.igt-word-col'); const words = Array.from(cols).map(col => ({ raw: col.dataset.raw, morph: col.querySelector('.igt-l2').innerText.trim(), gloss: col.querySelector('.igt-l3').innerText.trim(), confidence: 'high' })); const trans = block.querySelector('.igt-l4').innerText.replace(/^'|'$/g,'').trim(); payload.sentences.push({ words, translation: trans }); }); try { const res = await fetch('/api/download_word', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); const blob = await res.blob(); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `太魯閣語IGT標注成果_${new Date().toISOString().slice(0,10)}.docx`; a.click(); } catch(e) { alert('下載失敗,請確認標注後端已開啟。'); } }; // -- 7. Bridge: fill glosser tab from a search result sentence -- window.sendToGlosser = function(trukuSentence, chineseTranslation) { // Switch to glosser tab — 透過頁籤按鈕切換,與一般切換共用同一套 .active 樣式 // (原本改用 .hidden 切換,CSS 只認 .active,會造成畫面空白且其他頁籤無法再顯示) document.querySelector('[data-tab="tab-glosser"]').click(); // Pre-fill input fields document.getElementById('glosser-truku-input').value = trukuSentence; document.getElementById('glosser-chinese-input').value = chineseTranslation || ''; // Scroll up and auto-click analyze document.getElementById('glosser-truku-input').scrollIntoView({ behavior: 'smooth' }); setTimeout(() => { document.getElementById('glosserAnalyzeBtn').click(); }, 400); }; // ========================================================= // ☀️ Theme Toggle Logic (Light / Dark Mode) // ========================================================= document.addEventListener('DOMContentLoaded', () => { const themeToggleBtn = document.getElementById('themeToggleBtn'); if (!themeToggleBtn) return; // Default to dark mode unless 'light' is saved const savedTheme = localStorage.getItem('truku-dict-theme'); if (savedTheme === 'light') { document.documentElement.setAttribute('data-theme', 'light'); themeToggleBtn.innerHTML = '🌙 暗色模式'; } themeToggleBtn.addEventListener('click', () => { const currentTheme = document.documentElement.getAttribute('data-theme'); if (currentTheme === 'light') { document.documentElement.removeAttribute('data-theme'); // reverts to dark (default) localStorage.setItem('truku-dict-theme', 'dark'); themeToggleBtn.innerHTML = '☀️ 亮色模式'; } else { document.documentElement.setAttribute('data-theme', 'light'); localStorage.setItem('truku-dict-theme', 'light'); themeToggleBtn.innerHTML = '🌙 暗色模式'; } }); }); // ========================================================= // 📁 Workspace / Projects Logic // ========================================================= let currentActiveProjectId = null; let currentProjectSentences = []; let batchAbortController = null; async function loadProjectsList() { const container = document.getElementById('projects-list-container'); if (!container) return; try { const resp = await adminFetch('/api/projects'); const data = await resp.json(); const projects = data.projects || []; if (projects.length === 0) { container.innerHTML = `
尚無任何專案
`; return; } container.innerHTML = projects.map(p => `
${escapeHtml(p.name)}
共 ${p.total_count} 句 已完成 ${p.done_count}
`).join(''); } catch (e) { container.innerHTML = `
載入失敗: ${escapeHtml(e.message)}
`; } } window.openCreateProjectModal = async function() { const name = prompt('請輸入新專案名稱:\n(例如:第一冊第八課、賽德克故事選集)'); if (!name || !name.trim()) return; const description = prompt('請輸入專案描述(選填):') || ''; try { const resp = await adminFetch('/api/projects', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: name.trim(), description: description.trim() }) }); const data = await resp.json(); if (!resp.ok) throw new Error(data.message || data.error || '建立失敗'); await loadProjectsList(); selectProject(data.id); } catch (e) { alert(`❌ 建立失敗: ${e.message}`); } }; window.selectProject = async function(pid) { currentActiveProjectId = pid; const emptyState = document.getElementById('project-empty-state'); const activeWorkbench = document.getElementById('project-active-workbench'); if (emptyState) emptyState.classList.add('hidden'); if (activeWorkbench) activeWorkbench.classList.remove('hidden'); loadProjectsList(); try { const resp = await adminFetch(`/api/projects/${pid}`); if (!resp.ok) throw new Error('找不到此專案'); const data = await resp.json(); const p = data.project; currentProjectSentences = data.sentences || []; document.getElementById('current-proj-name').textContent = p.name; document.getElementById('current-proj-desc').textContent = p.description || '無描述'; const total = currentProjectSentences.length; const done = currentProjectSentences.filter(s => s.status === 'done').length; const pending = currentProjectSentences.filter(s => s.status === 'pending').length; document.getElementById('proj-stats-badge').innerHTML = `總計 ${total} 句 | 已完成 ${done} | 待標注 ${pending}`; renderProjectSentences(currentProjectSentences); } catch (e) { alert(`❌ 載入專案失敗: ${e.message}`); } }; function renderProjectSentences(sentences) { const container = document.getElementById('project-sentences-container'); if (!container) return; if (sentences.length === 0) { container.innerHTML = `
此專案尚無任何語料。
請上方點擊「📁 選擇檔案並匯入」上傳 Excel 或 TXT 檔案。
`; return; } container.innerHTML = sentences.map((s, idx) => { const igt = s.igt_result; const words = (igt && igt.words) ? igt.words : []; let statusBadge = `待標注`; if (s.status === 'done') { statusBadge = `✅ 標注完成`; } else if (s.status === 'error') { statusBadge = `❌ 錯誤`; } let wordsHtml = ''; if (words.length > 0) { wordsHtml = `
${words.map((w, wIdx) => `
${escapeHtml(w.raw || '')} ${escapeHtml(w.morph || '')} ${escapeHtml(w.gloss || '')}
`).join('')}
`; } return `
#${idx + 1} ${statusBadge}
${escapeHtml(s.truku_sentence)}
${escapeHtml(s.chinese_translation || '(無中文對照)')}
${wordsHtml}
`; }).join(''); } window.updateSentenceWord = async function(sid, wIdx, field, newText) { const s = currentProjectSentences.find(item => item.id === sid); if (!s || !s.igt_result || !s.igt_result.words) return; s.igt_result.words[wIdx][field] = newText.trim(); try { const resp = await adminFetch(`/api/projects/${currentActiveProjectId}/sentences/${sid}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ igt_result: s.igt_result }) }); if (!resp.ok) alert(`❌ 更新失敗:${await readErrorMessage(resp, '請稍後再試。')}`); } catch (e) { console.error('更新單句失敗:', e); } }; window.handleProjectFileSelect = async function(event) { const file = event.target.files[0]; if (!file || !currentActiveProjectId) return; const formData = new FormData(); formData.append('file', file); try { const resp = await adminFetch(`/api/projects/${currentActiveProjectId}/import`, { method: 'POST', body: formData }); const data = await resp.json(); if (!resp.ok) throw new Error(data.message || data.error || '匯入失敗'); alert(`✅ ${data.message}`); selectProject(currentActiveProjectId); } catch (e) { alert(`❌ 匯入失敗: ${e.message}`); } }; window.startBatchAnnotation = async function() { if (!currentActiveProjectId) return; const customApiKey = localStorage.getItem('custom_gemini_api_key') || ""; const progressContainer = document.getElementById('batch-progress-container'); const progressBar = document.getElementById('batch-progress-bar'); const progressText = document.getElementById('batch-progress-text'); const progressPct = document.getElementById('batch-progress-pct'); const startBtn = document.getElementById('start-batch-btn'); if (progressContainer) progressContainer.classList.remove('hidden'); if (startBtn) startBtn.disabled = true; // 改用 POST 串流(EventSource 只能 GET,金鑰會被放進網址) const pid = currentActiveProjectId; if (batchAbortController) batchAbortController.abort(); batchAbortController = new AbortController(); const handleBatchEvent = function(data) { if (data.event === 'progress') { const pct = data.progress; if (progressBar) progressBar.style.width = `${pct}%`; if (progressPct) progressPct.textContent = `${pct}%`; if (progressText) progressText.textContent = `[${data.current}/${data.total}] 正在標注:${data.truku_sentence}`; const s = currentProjectSentences.find(item => item.id === data.sentence_id); if (s) { s.status = data.status; s.igt_result = data.igt_result; renderProjectSentences(currentProjectSentences); } } else if (data.event === 'completed') { if (progressBar) progressBar.style.width = '100%'; if (progressPct) progressPct.textContent = '100%'; if (progressText) progressText.textContent = `✅ 批次標注完成!`; } }; try { const resp = await adminFetch(`/api/projects/${pid}/run_batch`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ custom_api_key: customApiKey }), signal: batchAbortController.signal }); if (!resp.ok) throw new Error(await readErrorMessage(resp, '批次標注啟動失敗')); const reader = resp.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let sep; while ((sep = buffer.indexOf('\n\n')) >= 0) { const chunk = buffer.slice(0, sep); buffer = buffer.slice(sep + 2); const line = chunk.split('\n').find(l => l.startsWith('data: ')); if (line) handleBatchEvent(JSON.parse(line.slice(6))); } } } catch (err) { if (err.name === 'AbortError') return; if (progressText) progressText.textContent = `⚠️ 批次標注中斷:${err.message},將重新載入專案...`; } finally { if (startBtn) startBtn.disabled = false; } if (currentActiveProjectId === pid) selectProject(pid); }; window.exportCurrentProject = function(fmt) { if (!currentActiveProjectId) return; // 匯出需要管理員憑證,無法用一般連結下載,改用 fetch 取得檔案後再存檔 adminFetch(`/api/projects/${currentActiveProjectId}/export?format=${encodeURIComponent(fmt)}`) .then(async res => { if (!res.ok) throw new Error(await readErrorMessage(res, '匯出失敗')); const blob = await res.blob(); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `專案匯出_IGT.${fmt === 'csv' ? 'csv' : 'docx'}`; a.click(); URL.revokeObjectURL(a.href); }) .catch(e => alert(`❌ ${e.message}`)); }; window.deleteCurrentProject = async function() { if (!currentActiveProjectId) return; if (!confirm('⚠️ 確定要刪除整個專案及其所有句子嗎?此動作無法復原!')) return; try { const resp = await adminFetch(`/api/projects/${currentActiveProjectId}`, { method: 'DELETE' }); if (!resp.ok) throw new Error(await readErrorMessage(resp, '刪除失敗')); currentActiveProjectId = null; document.getElementById('project-active-workbench').classList.add('hidden'); document.getElementById('project-empty-state').classList.remove('hidden'); loadProjectsList(); } catch (e) { alert(`❌ 刪除失敗: ${e.message}`); } }; // ═══════════════════════════════════════════════════ // 📊 使用量事件記錄工具函式 // ═══════════════════════════════════════════════════ /** * 非阻塞式事件記錄 — fire-and-forget * @param {string} eventType 'search' | 'tts' | 'igt_parse' | 'project_batch' * @param {object} eventData 附帶資料(關鍵字、語者等) */ window.logEvent = function(eventType, eventData = {}) { fetch('/api/log_event', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event_type: eventType, event_data: eventData }) }).catch(() => {}); // 靜默失敗,不影響主功能 }; // ═══════════════════════════════════════════════════ // 📊 使用量儀表板載入 & 渲染 // ═══════════════════════════════════════════════════ let usageTrendChart = null; let usageFeatureChart = null; let usageSearchTypeChart = null; let usageTtsSpeakerChart = null; async function loadUsageDashboard() { try { const resp = await adminFetch('/api/dashboard'); if (!resp.ok) throw new Error('API failed'); const d = await resp.json(); // ── 數字卡片 ── document.getElementById('usage-total-search').textContent = (d.total_search || 0).toLocaleString(); document.getElementById('usage-total-tts').textContent = (d.total_tts || 0).toLocaleString(); document.getElementById('usage-total-igt').textContent = (d.total_igt || 0).toLocaleString(); document.getElementById('usage-total-batch').textContent = (d.total_batch || 0).toLocaleString(); // ── 7日趨勢折線圖 ── const trendCtx = document.getElementById('usageTrendChart'); if (trendCtx) { if (usageTrendChart) usageTrendChart.destroy(); usageTrendChart = new Chart(trendCtx.getContext('2d'), { type: 'line', data: { labels: d.daily_trend.map(r => r.date.slice(5)), // MM-DD datasets: [{ label: '每日搜尋次數', data: d.daily_trend.map(r => r.count), borderColor: '#38bdf8', backgroundColor: 'rgba(56,189,248,0.15)', borderWidth: 2, pointBackgroundColor: '#38bdf8', pointRadius: 4, fill: true, tension: 0.4 }] }, options: { responsive: true, plugins: { legend: { display: false } }, scales: { x: { ticks: { color: '#94a3b8' }, grid: { color: 'rgba(255,255,255,0.05)' } }, y: { ticks: { color: '#94a3b8', stepSize: 1 }, grid: { color: 'rgba(255,255,255,0.05)' }, min: 0 } } } }); } // ── 功能使用環形圖 ── const featureCtx = document.getElementById('usageFeatureChart'); if (featureCtx) { if (usageFeatureChart) usageFeatureChart.destroy(); const featureLabels = { search: '搜尋', tts: 'TTS語音', igt_parse: 'IGT標注', project_batch: '批次標注' }; const fKeys = Object.keys(d.totals); const fVals = fKeys.map(k => d.totals[k]); const fColors = ['#38bdf8', '#34d399', '#a78bfa', '#fb923c']; usageFeatureChart = new Chart(featureCtx.getContext('2d'), { type: 'doughnut', data: { labels: fKeys.map(k => featureLabels[k] || k), datasets: [{ data: fVals, backgroundColor: fColors, borderWidth: 0 }] }, options: { responsive: true, plugins: { legend: { position: 'bottom', labels: { color: '#e2e8f0', boxWidth: 12 } } } } }); } // ── 搜尋類型橫條圖 ── const stCtx = document.getElementById('usageSearchTypeChart'); if (stCtx) { if (usageSearchTypeChart) usageSearchTypeChart.destroy(); const stLabelMap = { truku: '族語搜尋', chinese: '中文搜尋', source: '來源詞搜尋', unknown: '未知' }; const stKeys = Object.keys(d.search_type_dist); const stVals = stKeys.map(k => d.search_type_dist[k]); usageSearchTypeChart = new Chart(stCtx.getContext('2d'), { type: 'bar', data: { labels: stKeys.map(k => stLabelMap[k] || k), datasets: [{ label: '次數', data: stVals, backgroundColor: ['#60a5fa', '#a78bfa', '#34d399', '#94a3b8'], borderRadius: 6 }] }, options: { responsive: true, indexAxis: 'y', plugins: { legend: { display: false } }, scales: { x: { ticks: { color: '#94a3b8' }, grid: { color: 'rgba(255,255,255,0.05)' }, min: 0 }, y: { ticks: { color: '#e2e8f0' }, grid: { display: false } } } } }); } // ── TTS語者環形圖 ── const spCtx = document.getElementById('usageTtsSpeakerChart'); if (spCtx) { if (usageTtsSpeakerChart) usageTtsSpeakerChart.destroy(); const spLabelMap = { female: '女聲', male1: '男聲1', male2: '男聲2', unknown: '未知' }; const spKeys = Object.keys(d.tts_speaker_dist); const spVals = spKeys.map(k => d.tts_speaker_dist[k]); usageTtsSpeakerChart = new Chart(spCtx.getContext('2d'), { type: 'doughnut', data: { labels: spKeys.map(k => spLabelMap[k] || k), datasets: [{ data: spVals, backgroundColor: ['#f472b6', '#60a5fa', '#34d399', '#94a3b8'], borderWidth: 0 }] }, options: { responsive: true, plugins: { legend: { position: 'bottom', labels: { color: '#e2e8f0', boxWidth: 12 } } } } }); } // ── TOP 20 熱門搜尋詞 ── const topEl = document.getElementById('usage-top-queries'); if (topEl) { if (!d.top_queries || d.top_queries.length === 0) { topEl.innerHTML = '

尚無搜尋記錄。請先在「辭典查詢」頁籤搜尋幾個詞彙後再回來查看。

'; } else { const maxCnt = d.top_queries[0].count; topEl.innerHTML = d.top_queries.map((item, idx) => { const pct = Math.round((item.count / maxCnt) * 100); const medal = idx === 0 ? '🥇' : idx === 1 ? '🥈' : idx === 2 ? '🥉' : `${idx + 1}.`; return `
${medal} ${escapeHtml(item.query)}
${item.count}
`; }).join(''); } } } catch (e) { console.warn('[Dashboard] 載入失敗:', e.message); } } // ── 在 initSubTabs 中監聽 subtab-usage 點擊,自動重新載入儀表板 ── document.addEventListener('DOMContentLoaded', () => { document.addEventListener('click', (e) => { const btn = e.target.closest('.sub-tab-btn'); if (btn && btn.dataset.subtab === 'subtab-usage') { loadUsageDashboard(); } }); }); // ========================================================= // 📥 建議審核頁(管理員登入後才顯示) // 使用者送出的逐詞校正與例句建議,要管理員按「核准」才會真正寫入 // ========================================================= let reviewSection = 'suggestions'; let reviewStatus = 'pending'; let reviewItems = []; const FEEDBACK_TYPE_LABELS = { system_bug: '系統功能異常', sentence_error: '例句內容錯誤', suggestion: '功能建議' }; const REVIEW_FILTER_LABELS = { 'suggestions:pending': '待審核建議', 'suggestions:approved': '已核准', 'suggestions:rejected': '已退回', 'feedback:pending': '未處理回報', 'feedback:handled': '已處理回報' }; // SQLite 的 CURRENT_TIMESTAMP 是 UTC,顯示時轉成使用者當地時間 function formatUtcTimestamp(ts) { if (!ts) return ''; const d = new Date(String(ts).replace(' ', 'T') + 'Z'); return isNaN(d) ? String(ts) : d.toLocaleString('zh-TW', { hour12: false }); } document.addEventListener('DOMContentLoaded', () => { document.querySelectorAll('.review-filter-btn').forEach(btn => { btn.addEventListener('click', () => { reviewSection = btn.dataset.section; reviewStatus = btn.dataset.status; document.querySelectorAll('.review-filter-btn').forEach(b => b.classList.toggle('active', b === btn)); loadReviewTab(); }); }); }); // ── 資料保存狀態(Storage Bucket 是否掛載、最後備份時間、錯誤訊息) ── function formatAgo(epochSeconds, serverNow) { if (!epochSeconds) return ''; const sec = Math.max(0, Math.round((serverNow || Date.now() / 1000) - epochSeconds)); if (sec < 60) return `${sec} 秒前`; if (sec < 3600) return `${Math.round(sec / 60)} 分鐘前`; return new Date(epochSeconds * 1000).toLocaleString('zh-TW', { hour12: false }); } function renderStorageStatus(s, message) { const box = document.getElementById('storage-status'); if (!box) return; let level, headline, detail = ''; if (!s.mount_exists) { level = 'bad'; headline = '⚠️ 沒有偵測到 Storage Bucket:Space 重啟後,建議、回報、黃金記憶庫校正都會消失'; detail = '請到這個 Space 的 Settings →「Storage Buckets」掛載 Bucket,掛載路徑填 /data。'; } else if (s.last_error) { level = 'bad'; headline = '⚠️ 已偵測到 Bucket,但備份失敗'; detail = `錯誤訊息:${s.last_error}`; } else if (s.backup_exists) { level = 'ok'; headline = `✅ 資料保存正常:最後備份 ${formatAgo(s.backup_mtime, s.server_time)}`; detail = `備份位置:${s.backup_file}(${(s.backup_size / 1024).toFixed(1)} KB)`; } else { level = 'warn'; headline = '⏳ 已偵測到 Bucket,尚未產生備份'; detail = '有人送出建議、回報或審核後約 30 秒會自動備份;也可以按「立即備份」。'; } const color = { ok: '#10b981', warn: '#f59e0b', bad: '#ef4444' }[level]; box.style.borderColor = color; box.innerHTML = `
💾 ${escapeHtml(headline)}
${escapeHtml(detail)}
本站:${escapeHtml(s.site_env)}|啟動時:${escapeHtml(s.restore || '—')}${s.mount_exists && !s.mount_writable ? '|⚠️ 掛載點看起來是唯讀' : ''}
${message ? `
${escapeHtml(message)}
` : ''}
`; } async function loadStorageStatus() { try { const res = await adminFetch('/api/admin/storage_status'); if (res.ok) renderStorageStatus(await res.json()); } catch (e) { console.error(e); } } window.snapshotNow = async function(btn) { btn.disabled = true; btn.textContent = '備份中...'; try { const res = await adminFetch('/api/admin/storage_snapshot', { method: 'POST' }); const data = await res.json().catch(() => ({})); if (data.status) renderStorageStatus(data.status, data.message); else alert(data.message || '備份失敗'); } catch (e) { alert(`❌ ${e.message}`); btn.disabled = false; btn.textContent = '💾 立即備份'; } }; async function loadReviewTab() { const list = document.getElementById('review-list'); if (!list) return; loadStorageStatus(); list.innerHTML = '

載入中...

'; try { const sStatus = reviewSection === 'suggestions' ? reviewStatus : 'pending'; const fStatus = reviewSection === 'feedback' ? reviewStatus : 'pending'; const [sRes, fRes] = await Promise.all([ adminFetch(`/api/admin/suggestions?status=${encodeURIComponent(sStatus)}`), adminFetch(`/api/admin/feedback?status=${encodeURIComponent(fStatus)}`) ]); if (!sRes.ok || !fRes.ok) { const failed = sRes.ok ? fRes : sRes; const msg = await readErrorMessage(failed, '載入失敗'); if (failed.status === 403) await refreshAdminState(); throw new Error(msg); } const sData = await sRes.json(); const fData = await fRes.json(); updateReviewCounts(sData.counts || {}, fData.counts || {}); reviewItems = reviewSection === 'suggestions' ? (sData.suggestions || []) : (fData.feedback || []); if (!reviewItems.length) { list.innerHTML = '

目前沒有資料。

'; return; } list.innerHTML = reviewItems.map(item => reviewSection === 'suggestions' ? renderSuggestionCard(item) : renderFeedbackCard(item) ).join(''); } catch (e) { list.innerHTML = `

❌ ${escapeHtml(e.message)}

`; } } function updateReviewCounts(sCounts, fCounts) { document.querySelectorAll('.review-filter-btn').forEach(btn => { const counts = btn.dataset.section === 'suggestions' ? sCounts : fCounts; const label = REVIEW_FILTER_LABELS[`${btn.dataset.section}:${btn.dataset.status}`]; btn.textContent = `${label} (${counts[btn.dataset.status] || 0})`; }); } function renderSuggestionCard(item) { const p = item.payload || {}; const id = Number(item.id) || 0; const pending = item.status === 'pending'; const edit = pending ? 'class="review-editable" contenteditable="true"' : ''; let body; if (item.kind === 'correction') { const modeLabel = p.annotation_mode === 'semantic' ? '🔤 語意標注' : '🔬 構詞語法標注'; const rows = (p.words || []).map(w => ` ${escapeHtml(w.raw)} ${escapeHtml(w.morph)} ${escapeHtml(w.gloss)} `).join(''); body = `
${escapeHtml(p.truku_sentence || '')}
${escapeHtml(p.chinese_translation || '')}
${rows}
詞詞素切分 / ${modeLabel}標記
`; } else { body = `
族語句子
${escapeHtml(p.truku_sentence)}
中文翻譯
${escapeHtml(p.chinese_translation)}
`; } const kindLabel = item.kind === 'correction' ? '✏️ 逐詞校正(寫入黃金記憶庫)' : '📖 建議加入主辭典'; const note = item.note ? `
💬 使用者說明:${escapeHtml(item.note)}
` : ''; const reviewed = !pending ? `
審核時間:${escapeHtml(formatUtcTimestamp(item.reviewed_at))}${item.review_note ? `|審核備註:${escapeHtml(item.review_note)}` : ''}
` : ''; const actions = pending ? `
虛線內容可直接點擊修改後再核准
` : ''; return `
#${id}|${kindLabel}|送出時間:${escapeHtml(formatUtcTimestamp(item.created_at))}
${body}${note}${reviewed}${actions}
`; } function renderFeedbackCard(item) { const id = Number(item.id) || 0; const typeLabel = FEEDBACK_TYPE_LABELS[item.type] || item.type || '其他'; const sentence = item.sentence_text ? `
📌 相關例句(ID ${Number(item.sentence_id) || '-'}):${escapeHtml(item.sentence_text)}
` : ''; const email = item.email ? `
聯絡信箱:${escapeHtml(item.email)}
` : ''; const action = item.status === 'pending' ? `` : ``; return `
#${id}|${escapeHtml(typeLabel)}|回報時間:${escapeHtml(formatUtcTimestamp(item.timestamp))}
${escapeHtml(item.description)}
${sentence}${email}
${action}
`; } window.approveSuggestion = async function(btn, id) { const item = reviewItems.find(it => it.id === id); const card = btn.closest('.review-card'); if (!item || !card) return; const fieldText = (root, field) => (root.querySelector(`[data-field="${field}"]`)?.innerText || '').trim(); let payload; if (item.kind === 'correction') { payload = { ...item.payload, words: Array.from(card.querySelectorAll('tr[data-raw]')).map(tr => ({ raw: tr.dataset.raw, morph: fieldText(tr, 'morph'), gloss: fieldText(tr, 'gloss') })) }; } else { payload = { truku_sentence: fieldText(card, 'truku_sentence'), chinese_translation: fieldText(card, 'chinese_translation') }; } const target = item.kind === 'correction' ? '黃金記憶庫' : '主辭典'; if (!confirm(`確定核准並寫入${target}嗎?`)) return; btn.disabled = true; try { const res = await adminFetch(`/api/admin/suggestions/${id}/approve`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ payload }) }); if (!res.ok) throw new Error(await readErrorMessage(res, '核准失敗')); const data = await res.json(); alert(`✅ ${data.message}`); } catch (e) { alert(`❌ ${e.message}`); } loadReviewTab(); }; window.rejectSuggestion = async function(btn, id) { const note = prompt('退回原因(選填,只有管理員看得到):'); if (note === null) return; btn.disabled = true; try { const res = await adminFetch(`/api/admin/suggestions/${id}/reject`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ review_note: note }) }); if (!res.ok) throw new Error(await readErrorMessage(res, '退回失敗')); } catch (e) { alert(`❌ ${e.message}`); } loadReviewTab(); }; window.setFeedbackStatus = async function(btn, id, status) { btn.disabled = true; try { const res = await adminFetch(`/api/admin/feedback/${id}/status`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status }) }); if (!res.ok) throw new Error(await readErrorMessage(res, '更新失敗')); } catch (e) { alert(`❌ ${e.message}`); } loadReviewTab(); };