Spaces:
Running
Running
Download static/script.js from ILRDF-Lowking/truku-dictionary: direct link, hf CLI and curl.
- Browser
- Download file 131 kB
-
https://huggingface.co/spaces/ILRDF-Lowking/truku-dictionary/resolve/main/static/script.js
- Command line
-
hf download hf://spaces/ILRDF-Lowking/truku-dictionary/static/script.js
-
curl -L -o script.js https://huggingface.co/spaces/ILRDF-Lowking/truku-dictionary/resolve/main/static/script.js
131 kB
| // ========================================================= | |
| // 🛡️ 共用安全工具:資料庫與 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]) + '</span>'; | |
| 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 = `<p style="color: #ef4444;">${escapeHtml(msg)}</p>`; | |
| } | |
| } | |
| 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 => | |
| `<span class="collocation-badge" data-word="${escapeHtml(word)}">${escapeHtml(word)}</span>` | |
| ).join(''); | |
| collocationsHtml = ` | |
| <div style="margin-top: 1rem;"> | |
| <span style="color: var(--text-muted); margin-right: 0.5rem;">常用共現關聯詞:</span> | |
| <div class="collocations-container">${badgesHtml}</div> | |
| </div> | |
| `; | |
| } | |
| 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 = ` | |
| <div class="stats-grid" id="position-filters"> | |
| <div class="stat-item position-btn" data-pos="initial" style="cursor: pointer; transition: 0.2s;"> | |
| <div class="label">句首 (Initial)</div> | |
| <div class="value">${data.positions.initial} 次</div> | |
| <div class="pct">${pInit}%</div> | |
| </div> | |
| <div class="stat-item position-btn" data-pos="medial" style="cursor: pointer; transition: 0.2s;"> | |
| <div class="label">句中 (Medial)</div> | |
| <div class="value">${data.positions.medial} 次</div> | |
| <div class="pct">${pMed}%</div> | |
| </div> | |
| <div class="stat-item position-btn" data-pos="final" style="cursor: pointer; transition: 0.2s;"> | |
| <div class="label">句尾 (Final)</div> | |
| <div class="value">${data.positions.final} 次</div> | |
| <div class="pct">${pFin}%</div> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| } | |
| 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 = `<div style="line-height: 1.8;">💡 關鍵字 <span class="highlight-count">"${safeQuery}"</span> ${caseHint}在所選語料中部分搜尋共出現 <span class="highlight-count">${data.total_occurrences}</span> 次,其中精準全詞共出現 <span class="highlight-count">${data.exact_occurrences}</span> 次。<br>`; | |
| } else if (matchMode === 'prefix') { | |
| summaryText = `<div style="line-height: 1.8;">💡 關鍵字 <span class="highlight-count">"${safeQuery}"</span> ${caseHint}在所選語料中詞頭前綴搜尋共出現 <span class="highlight-count">${data.total_occurrences}</span> 次,其中精準全詞共出現 <span class="highlight-count">${data.exact_occurrences}</span> 次。<br>`; | |
| } else if (matchMode === 'infix') { | |
| summaryText = `<div style="line-height: 1.8;">💡 關鍵字 <span class="highlight-count">"${safeQuery}"</span> ${caseHint}在所選語料中詞中中綴搜尋共出現 <span class="highlight-count">${data.total_occurrences}</span> 次,其中精準全詞共出現 <span class="highlight-count">${data.exact_occurrences}</span> 次。<br>`; | |
| } else if (matchMode === 'suffix') { | |
| summaryText = `<div style="line-height: 1.8;">💡 關鍵字 <span class="highlight-count">"${safeQuery}"</span> ${caseHint}在所選語料中詞尾後綴搜尋共出現 <span class="highlight-count">${data.total_occurrences}</span> 次,其中精準全詞共出現 <span class="highlight-count">${data.exact_occurrences}</span> 次。<br>`; | |
| } else { | |
| summaryText = `<div style="line-height: 1.8;">💡 關鍵字 <span class="highlight-count">"${safeQuery}"</span> ${caseHint}在所選語料中共精準出現了 <span class="highlight-count">${data.total_occurrences}</span> 次。<br>`; | |
| } | |
| if (data.corpus_breakdown) { | |
| for (const [corpus, counts] of Object.entries(data.corpus_breakdown)) { | |
| summaryText += `💡 其中在 "${escapeHtml(corpus)}" 中共出現 <span class="highlight-count">${counts.total || counts.exact || 0}</span> 次。<br>`; | |
| } | |
| 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 `<div class="stat-item subcat-btn" data-subcat="${escapeHtml(subcat)}" style="cursor: pointer; transition: 0.2s; background: rgba(251, 146, 60, 0.1); border: 1px solid rgba(251, 146, 60, 0.3); padding: 0.5rem; border-radius: 6px; display: inline-block; min-width: 80px; text-align: center;"> | |
| <div style="color: #fb923c; font-weight: bold; font-size: 0.9rem;">${escapeHtml(subcat)}</div> | |
| <div style="color: #e2e8f0; font-size: 0.85rem;">${count} 筆</div> | |
| </div>`; | |
| }).join(''); | |
| summaryText += `<div style="margin-top: 0.5rem; margin-bottom: 1rem; display: flex; flex-wrap: wrap; gap: 0.5rem;" id="subcat-filters">${subBtnsHtml}</div>`; | |
| } | |
| } | |
| } | |
| summaryText += `</div>`; | |
| let ngramsHtml = ''; | |
| if (data.ngrams && (data.ngrams.bigrams?.length > 0 || data.ngrams.trigrams?.length > 0)) { | |
| const renderNgram = (ng) => `<div style="background: rgba(0,0,0,0.3); padding: 0.3rem 0.6rem; border-radius: 4px; display: inline-flex; align-items: center; gap: 0.5rem; margin: 0.2rem;"><span style="color: #e2e8f0;">${escapeHtml(ng.ngram)}</span><span style="color: #60a5fa; font-size: 0.85em;">${ng.count}次</span></div>`; | |
| const bigramsHtml = (data.ngrams.bigrams || []).map(renderNgram).join(''); | |
| const trigramsHtml = (data.ngrams.trigrams || []).map(renderNgram).join(''); | |
| ngramsHtml = ` | |
| <div style="margin-top: 1.5rem; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 1rem;"> | |
| <div style="color: var(--text-muted); margin-bottom: 0.5rem; font-weight: bold;">連詞分析 (N-grams):</div> | |
| ${bigramsHtml ? `<div style="margin-bottom: 0.5rem;"><span style="color: #94a3b8; font-size: 0.9em; width: 60px; display: inline-block;">雙詞:</span> ${bigramsHtml}</div>` : ''} | |
| ${trigramsHtml ? `<div><span style="color: #94a3b8; font-size: 0.9em; width: 60px; display: inline-block;">三詞:</span> ${trigramsHtml}</div>` : ''} | |
| </div> | |
| `; | |
| } | |
| let morphHtml = ''; | |
| if (data.morphology) { | |
| const familyBtns = data.morphology.family.map(fw => | |
| `<button onclick="document.getElementById('searchInput').value=${jsArg(fw)}; document.getElementById('searchBtn').click();" style="background: rgba(99,102,241,0.2); border: 1px solid rgba(99,102,241,0.5); color: #818cf8; padding: 0.3rem 0.6rem; border-radius: 4px; cursor: pointer; transition: all 0.2s; margin: 0.2rem;" onmouseover="this.style.background='rgba(99,102,241,0.4)'" onmouseout="this.style.background='rgba(99,102,241,0.2)'">${escapeHtml(fw)}</button>` | |
| ).join(''); | |
| morphHtml = ` | |
| <div style="margin-top: 1.5rem; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 1rem;"> | |
| <div style="color: #a78bfa; margin-bottom: 0.5rem; font-weight: bold;">✨ 構詞學分析 (Morphology):</div> | |
| <div style="margin-bottom: 0.5rem;"><span style="color: #94a3b8; font-size: 0.9em; width: 80px; display: inline-block;">推測詞根:</span> <span style="color: #e2e8f0; font-weight: bold;">${escapeHtml(data.morphology.root)}</span></div> | |
| <div><span style="color: #94a3b8; font-size: 0.9em; width: 80px; display: inline-block; vertical-align: top;">家族詞彙:</span> <div style="display: inline-block; width: calc(100% - 85px);">${familyBtns}</div></div> | |
| </div> | |
| `; | |
| } | |
| searchStatsSummary.innerHTML = ` | |
| <p>${summaryText}</p> | |
| ${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 = '<div style="padding: 1rem; color: #94a3b8; text-align: center;">沒有符合此條件的結果。</div>'; | |
| 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 = '<span style="color: var(--highlight-bg); font-weight: bold;">'; | |
| 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 = '<span class="list-item-badge badge-exact">精準</span>'; | |
| } else if (item.match_badge === 'prefix') { | |
| badgeHtml = '<span class="list-item-badge badge-prefix" style="background: rgba(167,139,250,0.25); color: #c4b5fd; border: 1px solid rgba(167,139,250,0.4);">前綴</span>'; | |
| } else if (item.match_badge === 'infix') { | |
| badgeHtml = '<span class="list-item-badge badge-infix" style="background: rgba(236,72,153,0.25); color: #f472b6; border: 1px solid rgba(236,72,153,0.4);">中綴</span>'; | |
| } else if (item.match_badge === 'suffix') { | |
| badgeHtml = '<span class="list-item-badge badge-suffix" style="background: rgba(16,185,129,0.25); color: #6ee7b7; border: 1px solid rgba(16,185,129,0.4);">後綴</span>'; | |
| } else { | |
| badgeHtml = '<span class="list-item-badge badge-partial">部分</span>'; | |
| } | |
| let corpusBadge = ''; | |
| let subcatBadge = ''; | |
| if (item.corpus_source === '族語辭典') { | |
| corpusBadge = '<span class="list-item-badge badge-corpus-dict">辭典</span>'; | |
| } else if (item.corpus_source === '族語E樂園') { | |
| corpusBadge = '<span class="list-item-badge badge-corpus-klokah">E樂園</span>'; | |
| if (item.detailed_source && item.detailed_source.trim() !== '') { | |
| subcatBadge = `<span class="list-item-badge badge-corpus-subcat">${escapeHtml(item.detailed_source.replace(/^族語E樂園 - /, ''))}</span>`; | |
| } | |
| } else if (item.corpus_source === '族語文學') { | |
| corpusBadge = '<span class="list-item-badge badge-corpus-lit">文學</span>'; | |
| } else if (item.corpus_source === '語推組織語料') { | |
| corpusBadge = '<span class="list-item-badge badge-corpus-org">語推</span>'; | |
| if (item.detailed_source && item.detailed_source.trim() !== '') { | |
| subcatBadge = `<span class="list-item-badge badge-corpus-subcat">${escapeHtml(item.detailed_source)}</span>`; | |
| } | |
| } else if (item.corpus_source === '族語資料庫語料') { | |
| corpusBadge = '<span class="list-item-badge badge-corpus-db">資料庫</span>'; | |
| } else if (item.corpus_source) { | |
| corpusBadge = `<span class="list-item-badge badge-corpus-dict">${escapeHtml(item.corpus_source)}</span>`; | |
| } | |
| li.innerHTML = ` | |
| <div class="list-item-truku">${corpusBadge} ${subcatBadge} ${displayTruku} ${badgeHtml}</div> | |
| <div class="list-item-chinese">${displayChinese}</div> | |
| `; | |
| 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 = '<span style="color: var(--highlight-bg); font-weight: bold;">'; | |
| const isTruku = currentSearchType === 'truku'; | |
| const isChinese = currentSearchType === 'chinese'; | |
| const isSource = currentSearchType === 'source'; | |
| const kwicText = highlightEscaped(item.kwic_sentence || '', isTruku ? highlightRegex : null, '<span class="keyword-highlight">'); | |
| 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 | |
| ? `<div class="kwic-text">搭配詞: ${kwicText}</div>` | |
| : `<div class="truku-text">${displayTruku}</div>`; | |
| const srcWord = item.source_word || ''; | |
| const srcTruku = item.truku_sentence || ''; | |
| const srcChinese = item.chinese_translation || ''; | |
| resultDetailCard.innerHTML = ` | |
| <div class="card"> | |
| <div class="card-header" style="display: flex; justify-content: space-between; align-items: flex-start;"> | |
| <div style="display:flex; align-items:center; gap:0.7rem; flex-wrap:wrap;"> | |
| <h2 style="margin:0;">${displaySource || '詳細資訊'}</h2> | |
| <span class="badge ${item.is_exact ? 'badge-exact' : 'badge-partial'}"> | |
| ${item.is_exact ? '精準匹配' : '部分匹配'} | |
| </span> | |
| </div> | |
| <a href="#" onclick="openReportModal('sentence_error', ${Number(item.id) || 0}, ${jsArg(srcTruku || srcWord)}); return false;" | |
| class="report-btn" style="padding: 0.3rem 0.6rem; font-size: 0.8rem; background: rgba(245, 158, 11, 0.1); color: #f59e0b; border-color: rgba(245, 158, 11, 0.3);"> | |
| <svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg> | |
| 回報此句 | |
| </a> | |
| </div> | |
| <!-- ── TTS 語音播放器 ── --> | |
| <div class="tts-player-bar"> | |
| <div class="tts-speaker-group"> | |
| <label class="tts-speaker-label">語者:</label> | |
| <select id="tts-speaker-select" class="tts-speaker-select"> | |
| <option value="female">🎙️ 女聲</option> | |
| <option value="male1">🎙️ 男聲 1</option> | |
| <option value="male2">🎙️ 男聲 2</option> | |
| </select> | |
| </div> | |
| <div class="tts-btn-group"> | |
| ${item.source_word ? ` | |
| <button class="tts-btn" id="tts-btn-word" | |
| onclick="playTTS('word', ${jsArg(srcWord)}, 'tts-btn-word')" | |
| title="朗讀詞頭"> | |
| 🔊 詞頭發音 | |
| </button>` : ''} | |
| <button class="tts-btn tts-btn-sentence" id="tts-btn-sentence" | |
| onclick="playTTS('sentence', ${jsArg(srcTruku || srcWord)}, 'tts-btn-sentence')" | |
| title="朗讀完整例句"> | |
| 🔊 例句朗讀 | |
| </button> | |
| </div> | |
| <div class="tts-status" id="tts-status"></div> | |
| </div> | |
| <audio id="tts-audio-player" style="display:none;"></audio> | |
| <div class="card-body"> | |
| ${kwicBlock} | |
| ${isTruku ? `<div class="truku-text" style="font-size: 0.9em; color: var(--text-muted);">完整例句: ${displayTruku}</div>` : ''} | |
| <div class="chinese-text">${displayChinese}</div> | |
| ${item.full_explanation ? `<div class="explanation">${escapeHtml(item.full_explanation).replace(/;/g, '<br>')}</div>` : ''} | |
| <!-- IGT Annotation Trigger --> | |
| <div style="margin-top: 1.2rem; padding-top: 1rem; border-top: 1px solid rgba(255,255,255,0.08); display:flex; gap:0.6rem; flex-wrap:wrap; align-items:center;"> | |
| <button | |
| onclick="triggerGlosser(this, ${jsArg(srcTruku)}, ${jsArg(srcChinese)})" | |
| style="background: rgba(139,92,246,0.15); color: #a78bfa; border: 1px solid rgba(139,92,246,0.4); padding: 0.35rem 0.9rem; border-radius: 6px; cursor: pointer; font-size: 0.85rem; font-weight: 600; transition: all 0.2s; display: inline-flex; align-items: center; gap: 0.4rem;" | |
| onmouseover="this.style.background='rgba(139,92,246,0.3)'" | |
| onmouseout="this.style.background='rgba(139,92,246,0.15)'" | |
| > | |
| 🏷️ 快速標注 (IGT) | |
| </button> | |
| <button | |
| onclick="sendToGlosser(${jsArg(srcTruku)}, ${jsArg(srcChinese)})" | |
| style="background: rgba(52,211,153,0.12); color: #34d399; border: 1px solid rgba(52,211,153,0.3); padding: 0.35rem 0.9rem; border-radius: 6px; cursor: pointer; font-size: 0.85rem; font-weight: 600; transition: all 0.2s; display: inline-flex; align-items: center; gap: 0.4rem;" | |
| onmouseover="this.style.background='rgba(52,211,153,0.25)'" | |
| onmouseout="this.style.background='rgba(52,211,153,0.12)'" | |
| > | |
| ↗️ 送往標注頁籤 | |
| </button> | |
| </div> | |
| <div class="igt-output-area" style="display:none; margin-top: 1rem;"></div> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| // ── 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 = `<p style="color: #ef4444;">載入統計資料失敗。</p>`; | |
| } | |
| } | |
| 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 => ` | |
| <div class="stats-list-row"> | |
| <div class="col-word"> | |
| ${escapeHtml(item.word)} | |
| ${item.translation ? `<span style="color:#94a3b8; font-size:0.85em; margin-left:0.5rem;">(${escapeHtml(item.translation)})</span>` : ''} | |
| </div> | |
| <div class="col-count">${item.count.toLocaleString()}</div> | |
| </div> | |
| `).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 => ` | |
| <div class="ngram-item"> | |
| <span class="ngram-text">${escapeHtml(item[0])}</span> | |
| <span class="ngram-count">${item[1].toLocaleString()}次</span> | |
| </div> | |
| `).join(''); | |
| const trigramsContainer = document.getElementById('stats-trigrams'); | |
| trigramsContainer.innerHTML = Object.entries(data.top_trigrams).sort((a,b)=>b[1]-a[1]).map(item => ` | |
| <div class="ngram-item"> | |
| <span class="ngram-text">${escapeHtml(item[0])}</span> | |
| <span class="ngram-count">${item[1].toLocaleString()}次</span> | |
| </div> | |
| `).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 += ` | |
| <tr style="border-bottom: 1px solid rgba(255,255,255,0.05); transition: background 0.2s;" onmouseover="this.style.background='rgba(255,255,255,0.05)'" onmouseout="this.style.background='transparent'"> | |
| <td style="padding: 0.75rem 1rem; color: #94a3b8;">${i + 1}</td> | |
| <td style="padding: 0.75rem 1rem;"> | |
| <span style="color: #e2e8f0; font-weight: 500;">${escapeHtml(dictItem.word)}</span> | |
| ${dictItem.word !== '-' ? `<span style="color: #60a5fa; margin-left: 0.5rem; font-size: 0.85em;">(${dictItem.count})</span><span style="color: rgba(255,255,255,0.4); margin-left: 0.3rem; font-size: 0.8em;">${dictItem.percentage}%</span>` : ''} | |
| ${dictItem.translation ? `<div style="color: #64748b; font-size: 0.8em; margin-top: 0.2rem;">${escapeHtml(dictItem.translation)}</div>` : ''} | |
| </td> | |
| <td style="padding: 0.75rem 1rem;"> | |
| <span style="color: #e2e8f0; font-weight: 500;">${escapeHtml(klokahItem.word)}</span> | |
| ${klokahItem.word !== '-' ? `<span style="color: #fde047; margin-left: 0.5rem; font-size: 0.85em;">(${klokahItem.count})</span><span style="color: rgba(255,255,255,0.4); margin-left: 0.3rem; font-size: 0.8em;">${klokahItem.percentage}%</span>` : ''} | |
| ${klokahItem.translation ? `<div style="color: #64748b; font-size: 0.8em; margin-top: 0.2rem;">${escapeHtml(klokahItem.translation)}</div>` : ''} | |
| </td> | |
| <td style="padding: 0.75rem 1rem;"> | |
| <span style="color: #e2e8f0; font-weight: 500;">${escapeHtml(litItem.word)}</span> | |
| ${litItem.word !== '-' ? `<span style="color: #10b981; margin-left: 0.5rem; font-size: 0.85em;">(${litItem.count})</span><span style="color: rgba(255,255,255,0.4); margin-left: 0.3rem; font-size: 0.8em;">${litItem.percentage}%</span>` : ''} | |
| ${litItem.translation ? `<div style="color: #64748b; font-size: 0.8em; margin-top: 0.2rem;">${escapeHtml(litItem.translation)}</div>` : ''} | |
| </td> | |
| </tr> | |
| `; | |
| } | |
| 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 = `<div style="color:#a78bfa; font-size:0.85rem; padding:0.5rem 0;">正在呼叫語言學 AI 標注引擎...</div>`; | |
| 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 = `<div style="color:#fca5a5;">標注引擎未回傳結果。</div>`; | |
| return; | |
| } | |
| // Build 4-line IGT HTML | |
| const usedMode = data.annotation_mode || requestMode; | |
| const modeLabel = usedMode === 'semantic' ? '🔤 語意標注' : '🔬 構詞語法標注'; | |
| let igtHtml = `<div class="igt-container">`; | |
| 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 ` | |
| <div class="igt-word-col ${isLow ? 'igt-low-conf' : ''}" data-idx="${sIdx}-${i}" data-raw="${escapeHtml(w.raw || '')}"> | |
| <div class="igt-l2" contenteditable="true" title="詞素切分(可編輯)">${escapeHtml(w.morph || w.raw || '')}</div> | |
| <div class="igt-l3" contenteditable="true" title="語法標記(可編輯)">${escapeHtml(w.gloss || '')}</div> | |
| </div>`; | |
| }).join(''); | |
| igtHtml += ` | |
| <div class="igt-block" data-sidx="${sIdx}" data-mode="${escapeHtml(usedMode)}"> | |
| <div style="font-size:0.72rem; color:#94a3b8; margin-bottom:0.3rem;">${modeLabel}</div> | |
| <div class="igt-l1">${escapeHtml(originalLine)}</div> | |
| <div class="igt-words-row">${wordColsHtml}</div> | |
| <div class="igt-l4">${escapeHtml(translation)}</div> | |
| <div class="igt-actions"> | |
| <button class="igt-save-btn" onclick="igtSave(this, ${sIdx})">${igtSaveLabel()}</button> | |
| <button class="igt-csv-btn" onclick="igtDownloadCsv(this)">📥 CSV</button> | |
| <button class="igt-word-btn" onclick="igtDownloadWord(this, ${sIdx})">📝 Word</button> | |
| <button class="igt-csv-btn igt-import-btn" style="background:#0ea5e9;color:white;border-color:#0284c7;" onclick="importToDictSentence(this)">${igtImportLabel()}</button> | |
| <span class="igt-save-msg" style="color:#4ade80; font-size:0.8rem; display:none;">✅ 已儲存!</span> | |
| </div> | |
| </div>`; | |
| }); | |
| igtHtml += `</div>`; | |
| 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 = `<div style="color:#fca5a5; font-size:0.85rem;">❌ 標注失敗:${escapeHtml(msg)}</div>`; | |
| 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 = `基於 <b>${Number(data.w1_count) || 0}</b> 句 ${escapeHtml(w1)} 與 <b>${Number(data.w2_count) || 0}</b> 句 ${escapeHtml(w2)} 例句進行分析`; | |
| } else { | |
| statsInfo.innerHTML = `基於 <b>${Number(data.w1_count) || 0}</b> 句 ${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, '<span style="color: #3b82f6; font-weight: bold;">$1</span>'); | |
| } | |
| if (w2) { | |
| const regex2 = new RegExp('\\b(' + escapeRegExp(w2) + ')\\b(?![^<]*>)', 'gi'); | |
| htmlContent = htmlContent.replace(regex2, '<span style="color: #ef4444; font-weight: bold;">$1</span>'); | |
| } | |
| 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 = `<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><style>body { color: #000000 !important; line-height: 1.8; font-family: sans-serif; }</style></head><body>`; | |
| const footer = `</body></html>`; | |
| 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' | |
| ? `<span style="background:rgba(139,92,246,0.2); color:#c4b5fd; border:1px solid rgba(139,92,246,0.4); padding:0.1rem 0.5rem; border-radius:999px; font-size:0.72rem; margin-left:0.5rem;">🔤 語意標注</span>` | |
| : `<span style="background:rgba(100,116,139,0.2); color:#94a3b8; border:1px solid rgba(100,116,139,0.4); padding:0.1rem 0.5rem; border-radius:999px; font-size:0.72rem; margin-left:0.5rem;">🔬 構詞語法標注</span>`; | |
| 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 ` | |
| <div class="igt-word-col ${isLow ? 'igt-low-conf' : ''}" | |
| data-raw="${escapeHtml(w.raw || '')}" | |
| data-sidx="${sIdx}" data-widx="${wIdx}"> | |
| <div class="igt-l2" contenteditable="true" title="詞素切分(可點擊編輯)">${escapeHtml(w.morph || w.raw || '')}</div> | |
| <div class="igt-l3" contenteditable="true" title="語法標記(可點擊編輯)">${escapeHtml(w.gloss || '')}</div> | |
| </div>`; | |
| }).join(''); | |
| return `<div class="igt-word-group" style="display:flex; gap:0;">${cols}</div>`; | |
| }).join(''); | |
| const block = document.createElement('div'); | |
| block.className = 'igt-block'; | |
| block.dataset.sidx = sIdx; | |
| block.dataset.mode = usedMode; | |
| block.innerHTML = ` | |
| <div style="font-size:0.75rem; color:#64748b; margin-bottom:0.5rem; font-weight:600;">句子成果 #${sIdx + 1}${modeBadge}</div> | |
| <div class="igt-l1">${escapeHtml(originalLine)}</div> | |
| <div class="igt-words-row">${wordColsHtml}</div> | |
| <div class="igt-l4" contenteditable="true" title="華語意譯(可點擊編輯)">${escapeHtml(translation)}</div> | |
| <div class="igt-actions"> | |
| <button class="igt-save-btn" onclick="glosserSaveSentence(this, ${sIdx})">${igtSaveLabel()}</button> | |
| <button class="igt-csv-btn igt-import-btn" style="background:#0ea5e9;color:white;border-color:#0284c7;" onclick="importToDictSentence(this)">${igtImportLabel()}</button> | |
| <span class="igt-save-msg" style="color:#4ade80;font-size:0.8rem;display:none;">✅ 已儲存!</span> | |
| </div> | |
| `; | |
| 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})!共處理 <b>${sentences.length}</b> 個句子,核心運算耗時 <b>${elapsed} 秒</b>。`; | |
| renderGlosserIGT(sentences, usedMode); | |
| batchBar.classList.remove('hidden'); | |
| } catch(e) { | |
| errorDiv.classList.remove('hidden'); | |
| if (e.message.includes('QUOTA_EXCEEDED')) { | |
| errorDiv.innerHTML = `❌ 系統公用額度已用完!<br><small style="color:#f87171;">請通知作者,或點擊右上角「⚙️ API 設定」填寫您個人的 Google Gemini API Key 後繼續使用。</small>`; | |
| } else if (e.message.includes('SERVICE_BUSY') || e.message.includes('503') || e.message.includes('high demand') || e.message.includes('UNAVAILABLE')) { | |
| errorDiv.innerHTML = `⚠️ <b>Google 伺服器目前尖峰塞車 (503 High Demand)</b><br><small style="color:#cbd5e1;">這是 Google 官方端暫時性算力滿載(免費版 Key 優先級較低時常發生)。<br>👉 <b>請稍等 10~20 秒後重新點擊「送出標注」</b>;若需大量穩定標注,更換為付費版 (Pay-as-you-go) 金鑰可徹底避免此問題。</small>`; | |
| } else { | |
| errorDiv.innerHTML = `❌ 標注失敗:${escapeHtml(e.message)}<br><small style="color:#94a3b8;">請檢查您的 API Key 是否有效,或稍後再試。</small>`; | |
| } | |
| } 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 = ` | |
| <div style="text-align: center; color: var(--text-muted); padding: 2rem 0.5rem; font-size: 0.88rem;"> | |
| 尚無任何專案<br> | |
| <button onclick="openCreateProjectModal()" style="margin-top: 0.8rem; background: rgba(16,185,129,0.15); color: #34d399; border: 1px solid rgba(16,185,129,0.3); padding: 0.35rem 0.8rem; border-radius: 6px; cursor: pointer; font-size: 0.82rem;">➕ 建立第一個專案</button> | |
| </div> | |
| `; | |
| return; | |
| } | |
| container.innerHTML = projects.map(p => ` | |
| <div onclick="selectProject(${p.id})" | |
| class="project-item-card ${currentActiveProjectId === p.id ? 'active' : ''}" | |
| style="background: ${currentActiveProjectId === p.id ? 'rgba(139,92,246,0.18)' : 'rgba(255,255,255,0.03)'}; border: 1px solid ${currentActiveProjectId === p.id ? 'rgba(139,92,246,0.5)' : 'rgba(255,255,255,0.08)'}; border-radius: 8px; padding: 0.7rem 0.9rem; cursor: pointer; transition: all 0.2s;"> | |
| <div style="font-weight: 600; font-size: 0.95rem; color: var(--text-color); margin-bottom: 0.3rem;">${escapeHtml(p.name)}</div> | |
| <div style="display: flex; justify-content: space-between; font-size: 0.78rem; color: var(--text-muted);"> | |
| <span>共 ${p.total_count} 句</span> | |
| <span style="color: #34d399;">已完成 ${p.done_count}</span> | |
| </div> | |
| </div> | |
| `).join(''); | |
| } catch (e) { | |
| container.innerHTML = `<div style="color: #ef4444; padding: 1rem;">載入失敗: ${escapeHtml(e.message)}</div>`; | |
| } | |
| } | |
| 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 = `總計 <b>${total}</b> 句 | 已完成 <b style="color:#34d399">${done}</b> | 待標注 <b style="color:#a78bfa">${pending}</b>`; | |
| 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 = ` | |
| <div style="text-align: center; padding: 3rem 1rem; color: var(--text-muted); background: rgba(255,255,255,0.02); border-radius: 8px;"> | |
| 此專案尚無任何語料。<br>請上方點擊「📁 選擇檔案並匯入」上傳 Excel 或 TXT 檔案。 | |
| </div> | |
| `; | |
| return; | |
| } | |
| container.innerHTML = sentences.map((s, idx) => { | |
| const igt = s.igt_result; | |
| const words = (igt && igt.words) ? igt.words : []; | |
| let statusBadge = `<span style="background: rgba(148,163,184,0.2); color: #94a3b8; padding: 0.2rem 0.6rem; border-radius: 4px; font-size: 0.78rem;">待標注</span>`; | |
| if (s.status === 'done') { | |
| statusBadge = `<span style="background: rgba(52,211,153,0.18); color: #34d399; padding: 0.2rem 0.6rem; border-radius: 4px; font-size: 0.78rem;">✅ 標注完成</span>`; | |
| } else if (s.status === 'error') { | |
| statusBadge = `<span style="background: rgba(239,68,68,0.18); color: #fca5a5; padding: 0.2rem 0.6rem; border-radius: 4px; font-size: 0.78rem;">❌ 錯誤</span>`; | |
| } | |
| let wordsHtml = ''; | |
| if (words.length > 0) { | |
| wordsHtml = ` | |
| <div style="display: flex; gap: 0.6rem; flex-wrap: wrap; margin-top: 0.8rem; background: rgba(0,0,0,0.2); padding: 0.8rem; border-radius: 8px;"> | |
| ${words.map((w, wIdx) => ` | |
| <div style="display: flex; flex-direction: column; align-items: center; background: rgba(255,255,255,0.05); padding: 0.4rem 0.6rem; border-radius: 6px; border: 1px solid rgba(255,255,255,0.08);"> | |
| <span style="font-size: 0.82rem; color: var(--text-muted);">${escapeHtml(w.raw || '')}</span> | |
| <span contenteditable="true" onblur="updateSentenceWord(${s.id}, ${wIdx}, 'morph', this.innerText)" style="font-weight: 600; color: #a78bfa; font-size: 0.92rem; outline: none; border-bottom: 1px dashed rgba(167,139,250,0.4);">${escapeHtml(w.morph || '')}</span> | |
| <span contenteditable="true" onblur="updateSentenceWord(${s.id}, ${wIdx}, 'gloss', this.innerText)" style="font-size: 0.85rem; color: #34d399; outline: none; border-bottom: 1px dashed rgba(52,211,153,0.4);">${escapeHtml(w.gloss || '')}</span> | |
| </div> | |
| `).join('')} | |
| </div> | |
| `; | |
| } | |
| return ` | |
| <div style="background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 10px; padding: 1rem;"> | |
| <div style="display: flex; justify-content: space-between; align-items: flex-start;"> | |
| <div> | |
| <span style="font-weight: bold; color: var(--text-muted); font-size: 0.85rem; margin-right: 0.5rem;">#${idx + 1}</span> | |
| ${statusBadge} | |
| </div> | |
| </div> | |
| <div style="margin-top: 0.6rem; font-weight: 600; font-size: 1.05rem; color: var(--text-color);">${escapeHtml(s.truku_sentence)}</div> | |
| <div style="color: var(--text-muted); font-size: 0.9rem; margin-top: 0.2rem;">${escapeHtml(s.chinese_translation || '(無中文對照)')}</div> | |
| ${wordsHtml} | |
| </div> | |
| `; | |
| }).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 = '<p style="color: var(--text-muted); font-size:0.88rem;">尚無搜尋記錄。請先在「辭典查詢」頁籤搜尋幾個詞彙後再回來查看。</p>'; | |
| } 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 ` | |
| <div style="display:flex; align-items:center; gap:0.75rem; padding:0.4rem 0.6rem; border-radius:6px; background:rgba(255,255,255,0.03);"> | |
| <span style="width:2rem; text-align:center; font-weight:bold; color:#94a3b8;">${medal}</span> | |
| <span style="flex:1; font-family:monospace; color:#f8fafc; font-size:0.95rem;">${escapeHtml(item.query)}</span> | |
| <div style="width:120px; height:6px; background:rgba(255,255,255,0.1); border-radius:3px; overflow:hidden;"> | |
| <div style="width:${pct}%; height:100%; background:#38bdf8; border-radius:3px;"></div> | |
| </div> | |
| <span style="color:#38bdf8; font-size:0.85rem; min-width:2.5rem; text-align:right;">${item.count}</span> | |
| </div>`; | |
| }).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 = ` | |
| <div style="display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap;"> | |
| <div> | |
| <div style="font-weight: 700; color: ${color};">💾 ${escapeHtml(headline)}</div> | |
| <div style="font-size: 0.85rem; color: var(--text-muted); margin-top: 0.3rem;">${escapeHtml(detail)}</div> | |
| <div style="font-size: 0.78rem; color: var(--text-muted); margin-top: 0.3rem;">本站:${escapeHtml(s.site_env)}|啟動時:${escapeHtml(s.restore || '—')}${s.mount_exists && !s.mount_writable ? '|⚠️ 掛載點看起來是唯讀' : ''}</div> | |
| ${message ? `<div style="font-size: 0.85rem; margin-top: 0.4rem; color: var(--text-color);">${escapeHtml(message)}</div>` : ''} | |
| </div> | |
| <button class="review-approve-btn" style="border-radius: 6px; padding: 0.35rem 0.9rem; cursor: pointer; font-weight: 600;" onclick="snapshotNow(this)">💾 立即備份</button> | |
| </div>`; | |
| } | |
| 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 = '<p style="color: var(--text-muted);">載入中...</p>'; | |
| 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 = '<p style="color: var(--text-muted); text-align: center; padding: 2rem 0;">目前沒有資料。</p>'; | |
| return; | |
| } | |
| list.innerHTML = reviewItems.map(item => | |
| reviewSection === 'suggestions' ? renderSuggestionCard(item) : renderFeedbackCard(item) | |
| ).join(''); | |
| } catch (e) { | |
| list.innerHTML = `<p style="color: #ef4444;">❌ ${escapeHtml(e.message)}</p>`; | |
| } | |
| } | |
| 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 => ` | |
| <tr data-raw="${escapeHtml(w.raw)}"> | |
| <td>${escapeHtml(w.raw)}</td> | |
| <td><span ${edit} data-field="morph">${escapeHtml(w.morph)}</span></td> | |
| <td><span ${edit} data-field="gloss">${escapeHtml(w.gloss)}</span></td> | |
| </tr>`).join(''); | |
| body = ` | |
| <div style="font-weight: 600; color: var(--text-color);">${escapeHtml(p.truku_sentence || '')}</div> | |
| <div style="color: var(--text-muted); font-size: 0.9rem; margin-top: 0.2rem;">${escapeHtml(p.chinese_translation || '')}</div> | |
| <table class="review-words"> | |
| <thead><tr><th>詞</th><th>詞素切分 / ${modeLabel}</th><th>標記</th></tr></thead> | |
| <tbody>${rows}</tbody> | |
| </table>`; | |
| } else { | |
| body = ` | |
| <div style="color: var(--text-muted); font-size: 0.8rem;">族語句子</div> | |
| <div style="font-weight: 600; color: var(--text-color); margin-bottom: 0.5rem;"><span ${edit} data-field="truku_sentence">${escapeHtml(p.truku_sentence)}</span></div> | |
| <div style="color: var(--text-muted); font-size: 0.8rem;">中文翻譯</div> | |
| <div style="color: var(--text-color);"><span ${edit} data-field="chinese_translation">${escapeHtml(p.chinese_translation)}</span></div>`; | |
| } | |
| const kindLabel = item.kind === 'correction' ? '✏️ 逐詞校正(寫入黃金記憶庫)' : '📖 建議加入主辭典'; | |
| const note = item.note | |
| ? `<div style="margin-top: 0.6rem; font-size: 0.88rem; color: var(--text-color);">💬 使用者說明:${escapeHtml(item.note)}</div>` : ''; | |
| const reviewed = !pending | |
| ? `<div style="margin-top: 0.6rem; font-size: 0.82rem; color: var(--text-muted);">審核時間:${escapeHtml(formatUtcTimestamp(item.reviewed_at))}${item.review_note ? `|審核備註:${escapeHtml(item.review_note)}` : ''}</div>` : ''; | |
| const actions = pending ? ` | |
| <div class="review-actions"> | |
| <button class="review-approve-btn" onclick="approveSuggestion(this, ${id})">✅ 核准寫入</button> | |
| <button class="review-reject-btn" onclick="rejectSuggestion(this, ${id})">↩️ 退回</button> | |
| <span style="font-size: 0.8rem; color: var(--text-muted); align-self: center;">虛線內容可直接點擊修改後再核准</span> | |
| </div>` : ''; | |
| return ` | |
| <div class="review-card" data-id="${id}"> | |
| <div class="review-card-meta">#${id}|${kindLabel}|送出時間:${escapeHtml(formatUtcTimestamp(item.created_at))}</div> | |
| ${body}${note}${reviewed}${actions} | |
| </div>`; | |
| } | |
| function renderFeedbackCard(item) { | |
| const id = Number(item.id) || 0; | |
| const typeLabel = FEEDBACK_TYPE_LABELS[item.type] || item.type || '其他'; | |
| const sentence = item.sentence_text | |
| ? `<div style="margin-top: 0.5rem; font-size: 0.9rem; color: var(--text-color);">📌 相關例句(ID ${Number(item.sentence_id) || '-'}):${escapeHtml(item.sentence_text)}</div>` : ''; | |
| const email = item.email | |
| ? `<div style="margin-top: 0.3rem; font-size: 0.82rem; color: var(--text-muted);">聯絡信箱:${escapeHtml(item.email)}</div>` : ''; | |
| const action = item.status === 'pending' | |
| ? `<button class="review-approve-btn" onclick="setFeedbackStatus(this, ${id}, 'handled')">✅ 標記已處理</button>` | |
| : `<button class="review-reject-btn" onclick="setFeedbackStatus(this, ${id}, 'pending')">↩️ 改回未處理</button>`; | |
| return ` | |
| <div class="review-card" data-id="${id}"> | |
| <div class="review-card-meta">#${id}|${escapeHtml(typeLabel)}|回報時間:${escapeHtml(formatUtcTimestamp(item.timestamp))}</div> | |
| <div style="white-space: pre-wrap; color: var(--text-color);">${escapeHtml(item.description)}</div> | |
| ${sentence}${email} | |
| <div class="review-actions">${action}</div> | |
| </div>`; | |
| } | |
| 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(); | |
| }; | |