💡 關鍵字
"${safeQuery}" ${caseHint}在所選語料中詞尾後綴搜尋共出現
${data.total_occurrences} 次,其中精準全詞共出現
${data.exact_occurrences} 次。
`;
} else {
summaryText = `
💡 關鍵字
"${safeQuery}" ${caseHint}在所選語料中共精準出現了
${data.total_occurrences} 次。
`;
}
if (data.corpus_breakdown) {
for (const [corpus, counts] of Object.entries(data.corpus_breakdown)) {
summaryText += `💡 其中在 "${escapeHtml(corpus)}" 中共出現
${counts.total || counts.exact || 0} 次。
`;
}
for (const [corpus, counts] of Object.entries(data.corpus_breakdown)) {
if (['族語E樂園', '語推組織語料'].includes(corpus) && counts.subcategories && Object.keys(counts.subcategories).length > 0) {
const subBtnsHtml = Object.entries(counts.subcategories).map(([subcat, count]) => {
return `
${escapeHtml(subcat)}
${count} 筆
`;
}).join('');
summaryText += `
${subBtnsHtml}
`;
}
}
}
summaryText += `
`;
let ngramsHtml = '';
if (data.ngrams && (data.ngrams.bigrams?.length > 0 || data.ngrams.trigrams?.length > 0)) {
const renderNgram = (ng) => `
${escapeHtml(ng.ngram)}${ng.count}次
`;
const bigramsHtml = (data.ngrams.bigrams || []).map(renderNgram).join('');
const trigramsHtml = (data.ngrams.trigrams || []).map(renderNgram).join('');
ngramsHtml = `
連詞分析 (N-grams):
${bigramsHtml ? `
雙詞: ${bigramsHtml}
` : ''}
${trigramsHtml ? `
三詞: ${trigramsHtml}
` : ''}
`;
}
let morphHtml = '';
if (data.morphology) {
const familyBtns = data.morphology.family.map(fw =>
`
`
).join('');
morphHtml = `
✨ 構詞學分析 (Morphology):
推測詞根: ${escapeHtml(data.morphology.root)}
`;
}
searchStatsSummary.innerHTML = `
${summaryText}
${morphHtml}
${collocationsHtml}
${ngramsHtml}
${positionsHtml}
`;
const badges = searchStatsSummary.querySelectorAll('.collocation-badge');
badges.forEach(badge => {
badge.addEventListener('click', () => {
const word = badge.getAttribute('data-word');
searchInput.value = word;
document.querySelector('input[name="searchType"][value="truku"]').checked = true;
performSearch();
});
});
// Position Filtering
const posBtns = searchStatsSummary.querySelectorAll('.position-btn');
let activePos = null;
posBtns.forEach(btn => {
btn.addEventListener('click', () => {
const pos = btn.getAttribute('data-pos');
if (activePos === pos) {
activePos = null;
posBtns.forEach(b => {
b.style.background = 'rgba(0,0,0,0.2)';
b.style.border = 'none';
});
renderResultsList(data.results, query, searchType, caseSensitive);
} else {
activePos = pos;
posBtns.forEach(b => {
b.style.background = 'rgba(0,0,0,0.2)';
b.style.border = 'none';
});
btn.style.background = 'rgba(59, 130, 246, 0.2)';
btn.style.border = '1px solid rgba(59, 130, 246, 0.5)';
const filtered = data.results.filter(r => r.positions && r.positions.includes(pos));
renderResultsList(filtered, query, searchType, caseSensitive);
}
});
});
// Subcategory Filtering
const subcatBtns = searchStatsSummary.querySelectorAll('.subcat-btn');
let activeSubcat = null;
subcatBtns.forEach(btn => {
btn.addEventListener('click', () => {
const subcat = btn.getAttribute('data-subcat');
if (activeSubcat === subcat) {
activeSubcat = null;
subcatBtns.forEach(b => {
b.style.background = 'rgba(251, 146, 60, 0.1)';
b.style.border = '1px solid rgba(251, 146, 60, 0.3)';
});
renderResultsList(data.results, query, searchType, caseSensitive);
} else {
activeSubcat = subcat;
subcatBtns.forEach(b => {
b.style.background = 'rgba(0,0,0,0.2)';
b.style.border = '1px solid transparent';
});
btn.style.background = 'rgba(251, 146, 60, 0.3)';
btn.style.border = '1px solid rgba(251, 146, 60, 0.8)';
const filtered = data.results.filter(r => r.detailed_source && r.detailed_source.includes(subcat));
renderResultsList(filtered, query, searchType, caseSensitive);
}
});
});
}
function renderResultsList(results, query, searchType, caseSensitive = false) {
resultsList.innerHTML = '';
let displayResults = results;
let isTruncated = false;
if (results.length > 200) {
displayResults = results.slice(0, 200);
isTruncated = true;
}
if (displayResults.length === 0) {
resultsList.innerHTML = '
沒有符合此條件的結果。
';
return;
}
const cleanQ = query.replace(/^[-<*]+|[-<*]+$/g, '');
const flags = caseSensitive ? 'g' : 'gi';
const highlightRegex = cleanQ ? new RegExp(`(${escapeRegExp(cleanQ)})`, flags) : null;
displayResults.forEach((item, index) => {
const li = document.createElement('div');
li.className = `list-item ${index === 0 ? 'active' : ''}`;
const hlTag = '
';
const hlTruku = highlightRegex && (searchType === 'truku' || searchType === 'source');
const hlChinese = highlightRegex && searchType === 'chinese';
const displayTruku = highlightEscaped(item.truku_sentence || '', hlTruku ? highlightRegex : null, hlTag);
const displayChinese = highlightEscaped(item.chinese_translation || '', hlChinese ? highlightRegex : null, hlTag);
let badgeHtml = '';
if (item.match_badge === 'exact' || (item.is_exact && !['prefix','suffix','infix'].includes(item.match_badge))) {
badgeHtml = '精準';
} else if (item.match_badge === 'prefix') {
badgeHtml = '前綴';
} else if (item.match_badge === 'infix') {
badgeHtml = '中綴';
} else if (item.match_badge === 'suffix') {
badgeHtml = '後綴';
} else {
badgeHtml = '部分';
}
let corpusBadge = '';
let subcatBadge = '';
if (item.corpus_source === '族語辭典') {
corpusBadge = '辭典';
} else if (item.corpus_source === '族語E樂園') {
corpusBadge = 'E樂園';
if (item.detailed_source && item.detailed_source.trim() !== '') {
subcatBadge = `${escapeHtml(item.detailed_source.replace(/^族語E樂園 - /, ''))}`;
}
} else if (item.corpus_source === '族語文學') {
corpusBadge = '文學';
} else if (item.corpus_source === '語推組織語料') {
corpusBadge = '語推';
if (item.detailed_source && item.detailed_source.trim() !== '') {
subcatBadge = `${escapeHtml(item.detailed_source)}`;
}
} else if (item.corpus_source === '族語資料庫語料') {
corpusBadge = '資料庫';
} else if (item.corpus_source) {
corpusBadge = `${escapeHtml(item.corpus_source)}`;
}
li.innerHTML = `
${corpusBadge} ${subcatBadge} ${displayTruku} ${badgeHtml}
${displayChinese}
`;
li.addEventListener('click', () => {
document.querySelectorAll('.list-item').forEach(el => el.classList.remove('active'));
li.classList.add('active');
renderDetailCard(item);
});
resultsList.appendChild(li);
});
if (isTruncated) {
const truncRow = document.createElement('div');
truncRow.style.padding = '1rem';
truncRow.style.textAlign = 'center';
truncRow.style.color = '#94a3b8';
truncRow.style.fontSize = '0.9rem';
truncRow.innerHTML = `已隱藏其餘 ${results.length - 200} 筆結果。請縮小搜尋範圍或點擊「匯出 CSV」檢視全部資料。`;
resultsList.appendChild(truncRow);
}
if (displayResults.length > 0) {
renderDetailCard(displayResults[0]);
}
}
function renderDetailCard(item) {
emptyDetailState.style.display = 'none';
resultDetailCard.classList.remove('hidden');
const highlightRegex = currentQuery ? new RegExp(`(${escapeRegExp(currentQuery)})`, 'gi') : null;
const hlTag = '';
const isTruku = currentSearchType === 'truku';
const isChinese = currentSearchType === 'chinese';
const isSource = currentSearchType === 'source';
const kwicText = highlightEscaped(item.kwic_sentence || '', isTruku ? highlightRegex : null, '');
const displayTruku = highlightEscaped(item.truku_sentence || '', (isTruku || isSource) ? highlightRegex : null, hlTag);
const displayChinese = highlightEscaped(item.chinese_translation || '', isChinese ? highlightRegex : null, hlTag);
const displaySource = highlightEscaped(item.source_word || '', isSource ? highlightRegex : null, hlTag);
const kwicBlock = isTruku
? `搭配詞: ${kwicText}
`
: `${displayTruku}
`;
const srcWord = item.source_word || '';
const srcTruku = item.truku_sentence || '';
const srcChinese = item.chinese_translation || '';
resultDetailCard.innerHTML = `
${item.source_word ? `
` : ''}
${kwicBlock}
${isTruku ? `
完整例句: ${displayTruku}
` : ''}
${displayChinese}
${item.full_explanation ? `
${escapeHtml(item.full_explanation).replace(/;/g, '
')}
` : ''}
`;
}
// ── TTS 語音播放函式 ──
window.playTTS = async function(type, text, btnId) {
const btn = document.getElementById(btnId);
const statusEl = document.getElementById('tts-status');
const audioEl = document.getElementById('tts-audio-player');
const speakerSelect = document.getElementById('tts-speaker-select');
if (!btn || !statusEl || !audioEl) return;
const speaker = speakerSelect ? speakerSelect.value : 'female';
const originalHTML = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '⏳ 生成中...';
statusEl.textContent = '正在呼叫原語會 TTS,請稍候(約 3~6 秒)...';
statusEl.style.color = 'var(--text-muted)';
try {
const params = new URLSearchParams({ text, speaker });
const resp = await fetch(`/api/tts?${params.toString()}`);
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.error || 'TTS 服務異常');
}
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
audioEl.src = url;
audioEl.style.display = 'block';
audioEl.play();
const cacheHit = resp.headers.get('X-Cache') === 'HIT';
statusEl.textContent = cacheHit ? '✅ 快取命中,立即播放!' : '✅ 語音生成完畢,正在播放...';
statusEl.style.color = '#34d399';
// ── 記錄 TTS 事件 ──
logEvent('tts', { type, speaker, cache_hit: cacheHit, text_length: text.length });
audioEl.onended = () => { statusEl.textContent = ''; };
} catch (e) {
statusEl.textContent = `❌ ${e.message}`;
statusEl.style.color = '#f87171';
} finally {
btn.disabled = false;
btn.innerHTML = originalHTML;
}
};
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// --- IGT Glosser Integration ---
// Calls truku-glosser backend (port 8000) and renders 4-line IGT format
// --- Stats Logic ---
async function loadStats() {
statsLoading.classList.remove('hidden');
try {
const response = await fetch('/api/stats');
if (!response.ok) throw new Error('Network response was not ok');
const data = await response.json();
statsLoading.classList.add('hidden');
statsDashboard.classList.remove('hidden');
document.getElementById('stat-entries').innerText = data.total_entries.toLocaleString();
document.getElementById('stat-sentences').innerText = data.total_sentences.toLocaleString();
document.getElementById('stat-avg-len').innerText = data.avg_sentence_length.toLocaleString();
initSubTabs();
renderCharts(data);
statsLoaded = true;
} catch (error) {
console.error('Error fetching stats:', error);
statsLoading.innerHTML = `載入統計資料失敗。
`;
}
}
function initSubTabs() {
const subTabBtns = document.querySelectorAll('.sub-tab-btn');
const subTabContents = document.querySelectorAll('.sub-tab-content');
subTabBtns.forEach(btn => {
btn.addEventListener('click', () => {
subTabBtns.forEach(b => b.classList.remove('active'));
subTabContents.forEach(c => c.classList.add('hidden'));
btn.classList.add('active');
document.getElementById(btn.dataset.subtab).classList.remove('hidden');
// Force charts to resize when their container becomes visible
setTimeout(() => {
if (wordChart) wordChart.resize();
if (letterChart) letterChart.resize();
if (prefixChart) prefixChart.resize();
if (suffixChart) suffixChart.resize();
}, 10);
});
});
}
function renderCharts(data) {
Chart.defaults.color = '#94a3b8';
Chart.defaults.borderColor = 'rgba(255, 255, 255, 0.1)';
if (typeof ChartDataLabels !== 'undefined') {
Chart.register(ChartDataLabels);
}
// 1. Corpus Pie Chart
const pieCtx = document.getElementById('corpusPieChart').getContext('2d');
const corpusKeys = Object.keys(data.corpus_distribution);
const corpusValues = corpusKeys.map(k => data.corpus_distribution[k]);
const corpusColors = corpusKeys.map(k => {
if (k === '族語辭典') return '#60a5fa';
if (k === '族語E樂園') return '#a855f7';
if (k === '族語文學') return '#10b981';
return '#f59e0b';
});
if (corpusPieChart) corpusPieChart.destroy();
corpusPieChart = new Chart(pieCtx, {
type: 'doughnut',
data: {
labels: corpusKeys,
datasets: [{
data: corpusValues,
backgroundColor: corpusColors,
borderWidth: 0
}]
},
options: {
responsive: true,
plugins: {
legend: { position: 'bottom', labels: { color: '#e2e8f0' } },
datalabels: {
color: '#fff',
font: { weight: 'bold' },
formatter: (value, ctx) => {
let sum = 0;
let dataArr = ctx.chart.data.datasets[0].data;
dataArr.map(data => { sum += data; });
let percentage = (value*100 / sum).toFixed(1)+"%";
return percentage;
}
}
}
}
});
// 2. Letter Chart
const letterCtx = document.getElementById('letterChart').getContext('2d');
const letterKeys = Object.keys(data.first_letters).sort();
const letterValues = letterKeys.map(k => data.first_letters[k]);
const bgColors = letterKeys.map((_, i) => `hsla(${i * (360/letterKeys.length)}, 70%, 60%, 0.6)`);
const borderColors = letterKeys.map((_, i) => `hsla(${i * (360/letterKeys.length)}, 70%, 60%, 1)`);
if (letterChart) letterChart.destroy();
letterChart = new Chart(letterCtx, {
type: 'bar',
data: {
labels: letterKeys.map(k => k.toUpperCase()),
datasets: [{
label: '詞條數量',
data: letterValues,
backgroundColor: bgColors,
borderColor: borderColors,
borderWidth: 1
}]
},
options: {
responsive: true,
plugins: {
legend: { display: false },
datalabels: {
color: '#e2e8f0',
anchor: 'end',
align: 'top',
formatter: Math.round,
font: { weight: 'bold' }
}
}
}
});
// Prefix Chart
if (data.prefixes) {
const preCtx = document.getElementById('prefixChart').getContext('2d');
const preKeys = Object.keys(data.prefixes);
const preVals = preKeys.map(k => data.prefixes[k]);
if (prefixChart) prefixChart.destroy();
prefixChart = new Chart(preCtx, {
type: 'bar',
data: {
labels: preKeys,
datasets: [{
data: preVals,
backgroundColor: 'rgba(236, 72, 153, 0.6)',
borderColor: 'rgba(236, 72, 153, 1)',
borderWidth: 1
}]
},
options: {
responsive: true,
plugins: { legend: { display: false }, datalabels: { color: '#e2e8f0', anchor: 'end', align: 'top', font: { weight: 'bold' } } }
}
});
}
// Suffix Chart
if (data.suffixes) {
const sufCtx = document.getElementById('suffixChart').getContext('2d');
const sufKeys = Object.keys(data.suffixes);
const sufVals = sufKeys.map(k => data.suffixes[k]);
if (suffixChart) suffixChart.destroy();
suffixChart = new Chart(sufCtx, {
type: 'bar',
data: {
labels: sufKeys,
datasets: [{
data: sufVals,
backgroundColor: 'rgba(16, 185, 129, 0.6)',
borderColor: 'rgba(16, 185, 129, 1)',
borderWidth: 1
}]
},
options: {
responsive: true,
plugins: { legend: { display: false }, datalabels: { color: '#e2e8f0', anchor: 'end', align: 'top', font: { weight: 'bold' } } }
}
});
}
// 3. Top Words
const top50 = data.top_words;
const topWordsKeys = top50.map(item => item.word);
const topWordsValues = top50.map(item => item.count);
if (wordChart) wordChart.destroy();
const wordCtx = document.getElementById('wordChart').getContext('2d');
wordChart = new Chart(wordCtx, {
type: 'bar',
data: {
labels: topWordsKeys,
datasets: [{
label: '出現次數',
data: topWordsValues,
backgroundColor: '#3b82f6',
borderRadius: 4
}]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
datalabels: {
color: '#e2e8f0',
anchor: 'end',
align: 'right',
formatter: Math.round,
font: { weight: 'bold', size: 10 }
}
},
scales: {
x: { grid: { color: 'rgba(255,255,255,0.05)' }, max: Math.max(...topWordsValues) * 1.15 },
y: { grid: { display: false } }
}
}
});
// Populate Left List
const listContainer = document.getElementById('stats-word-list');
listContainer.innerHTML = top50.map(item => `
${escapeHtml(item.word)}
${item.translation ? `(${escapeHtml(item.translation)})` : ''}
${item.count.toLocaleString()}
`).join('');
// Populate N-grams
const bigramsContainer = document.getElementById('stats-bigrams');
bigramsContainer.innerHTML = Object.entries(data.top_bigrams).sort((a,b)=>b[1]-a[1]).map(item => `
${escapeHtml(item[0])}
${item[1].toLocaleString()}次
`).join('');
const trigramsContainer = document.getElementById('stats-trigrams');
trigramsContainer.innerHTML = Object.entries(data.top_trigrams).sort((a,b)=>b[1]-a[1]).map(item => `
${escapeHtml(item[0])}
${item[1].toLocaleString()}次
`).join('');
// Populate Corpus Compare Table
if (data.top_words_by_corpus) {
const tableBody = document.querySelector('#corpus-compare-table tbody');
const dictTop = data.top_words_by_corpus['族語辭典'] || [];
const klokahTop = data.top_words_by_corpus['族語E樂園'] || [];
const litTop = data.top_words_by_corpus['族語文學'] || [];
let tableHtml = '';
for (let i = 0; i < 50; i++) {
const dictItem = dictTop[i] || {word: '-', count: 0, translation: ''};
const klokahItem = klokahTop[i] || {word: '-', count: 0, translation: ''};
const litItem = litTop[i] || {word: '-', count: 0, translation: ''};
tableHtml += `
| ${i + 1} |
${escapeHtml(dictItem.word)}
${dictItem.word !== '-' ? `(${dictItem.count})${dictItem.percentage}%` : ''}
${dictItem.translation ? ` ${escapeHtml(dictItem.translation)} ` : ''}
|
${escapeHtml(klokahItem.word)}
${klokahItem.word !== '-' ? `(${klokahItem.count})${klokahItem.percentage}%` : ''}
${klokahItem.translation ? ` ${escapeHtml(klokahItem.translation)} ` : ''}
|
${escapeHtml(litItem.word)}
${litItem.word !== '-' ? `(${litItem.count})${litItem.percentage}%` : ''}
${litItem.translation ? ` ${escapeHtml(litItem.translation)} ` : ''}
|
`;
}
if (tableBody) tableBody.innerHTML = tableHtml;
}
}
});
window.openReportModal = function(type, id = null, sentence = '') {
const modal = document.getElementById('reportModal');
const typeSelect = document.getElementById('reportType');
const sentenceGroup = document.getElementById('reportSentenceGroup');
const sentenceArea = document.getElementById('reportSentence');
const idInput = document.getElementById('reportSentenceId');
typeSelect.value = type;
document.getElementById('reportDescription').value = '';
document.getElementById('reportMessage').innerHTML = '';
if (type === 'sentence_error' && id) {
sentenceGroup.style.display = 'block';
sentenceArea.value = sentence;
idInput.value = id;
} else {
sentenceGroup.style.display = 'none';
sentenceArea.value = '';
idInput.value = '';
}
modal.classList.remove('hidden');
}
window.closeReportModal = function() {
document.getElementById('reportModal').classList.add('hidden');
}
window.submitReport = async function() {
const type = document.getElementById('reportType').value;
const description = document.getElementById('reportDescription').value;
const email = document.getElementById('reportEmail').value;
const sentenceId = document.getElementById('reportSentenceId').value;
const sentence = document.getElementById('reportSentence').value;
if (!description.trim()) {
alert('請填寫詳細說明!');
return;
}
const btn = document.getElementById('submitReportBtn');
const msg = document.getElementById('reportMessage');
btn.disabled = true;
btn.innerText = '傳送中...';
msg.innerHTML = '';
try {
const res = await fetch('/api/report', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type, description, email, sentenceId, sentence })
});
const data = await res.json();
if (res.ok) {
msg.style.color = '#10b981';
msg.innerHTML = '✅ 回報已成功送出給 lowking@ilrdf.org.tw!謝謝您!';
setTimeout(() => closeReportModal(), 2000);
} else {
throw new Error(data.message || data.error || 'Server error');
}
} catch (error) {
msg.style.color = '#ef4444';
msg.textContent = `❌ 傳送失敗:${error.message}`;
} finally {
btn.disabled = false;
btn.innerText = '送出回報';
}
}
// Global variables to store current analyzed words for export
let currentAiWord1 = '';
let currentAiWord2 = '';
// =========================================================
// 🔤 語意標注 / 🔬 構詞語法標注 模式切換(批次標注面板專用)
// 'grammar' 為既有預設模式(含焦點/時貌/格位等語言學術語),
// 'semantic' 為新增模式(每個詞只給白話中文意思,適合初學族人)
// =========================================================
let glosserMode = 'grammar';
window.setGlosserMode = function(mode) {
glosserMode = mode;
const semBtn = document.getElementById('glosser-mode-semantic');
const gramBtn = document.getElementById('glosser-mode-grammar');
if (!semBtn || !gramBtn) return;
if (mode === 'semantic') {
semBtn.style.background = '#7c3aed';
semBtn.style.color = 'white';
gramBtn.style.background = 'transparent';
gramBtn.style.color = '#94a3b8';
} else {
gramBtn.style.background = '#7c3aed';
gramBtn.style.color = 'white';
semBtn.style.background = 'transparent';
semBtn.style.color = '#94a3b8';
}
};
// =========================================================
// 🏷️ IGT Glosser Integration — triggerGlosser()
// Calls truku-glosser backend on port 8000, renders 4-line IGT
// =========================================================
window.triggerGlosser = async function(btn, trukuSentence, chineseTranslation) {
const outputArea = btn.closest('.card-body').querySelector('.igt-output-area');
// Toggle: if already showing, hide it
if (outputArea.style.display !== 'none' && outputArea.innerHTML !== '') {
outputArea.style.display = 'none';
btn.innerHTML = '🏷️ 語言學標注 (IGT)';
return;
}
btn.innerHTML = '⏳ 分析中...';
btn.disabled = true;
outputArea.style.display = 'block';
outputArea.innerHTML = `正在呼叫語言學 AI 標注引擎...
`;
try {
const customApiKey = localStorage.getItem('custom_gemini_api_key') || "";
const requestMode = glosserMode;
const response = await fetch('/api/parse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ truku_sentence: trukuSentence, chinese_translation: chineseTranslation, custom_api_key: customApiKey, annotation_mode: requestMode })
});
if (response.status === 429) {
const errData = await response.json().catch(() => ({}));
throw new Error(errData.detail === 'RATE_LIMITED' ? errData.message : 'QUOTA_EXCEEDED');
}
if (response.status === 503) {
const errData = await response.json().catch(() => ({}));
throw new Error(errData.message || 'SERVICE_BUSY');
}
if (!response.ok) {
let errText = await response.text();
try {
const parsed = JSON.parse(errText);
if (parsed.message) errText = parsed.message;
else if (parsed.detail) errText = parsed.detail;
} catch(ign) {}
throw new Error(errText);
}
const data = await response.json();
const sentences = data.sentences || [];
if (sentences.length === 0) {
outputArea.innerHTML = `標注引擎未回傳結果。
`;
return;
}
// Build 4-line IGT HTML
const usedMode = data.annotation_mode || requestMode;
const modeLabel = usedMode === 'semantic' ? '🔤 語意標注' : '🔬 構詞語法標注';
let igtHtml = ``;
sentences.forEach((sentObj, sIdx) => {
const words = sentObj.words || [];
const translation = sentObj.translation || chineseTranslation || '';
// Build the raw sentence (Line 1) from words
const originalLine = words.map(w => w.raw).join(' ');
// Word columns for lines 2 & 3
const wordColsHtml = words.map((w, i) => {
const isLow = w.confidence === 'low';
return `
${escapeHtml(w.morph || w.raw || '')}
${escapeHtml(w.gloss || '')}
`;
}).join('');
igtHtml += `
${modeLabel}
${escapeHtml(originalLine)}
${wordColsHtml}
${escapeHtml(translation)}
✅ 已儲存!
`;
});
igtHtml += `
`;
outputArea.innerHTML = igtHtml;
// Store raw data on the container for download functions
outputArea._glosserData = sentences;
// ── 記錄 IGT 標注事件 ──
logEvent('igt_parse', { sentence_length: trukuSentence.length, word_count: sentences.reduce((a, s) => a + (s.words || []).length, 0) });
btn.innerHTML = '🏷️ 收起標注';
} catch (e) {
let msg = e.message;
if (msg.includes('SERVICE_BUSY') || msg.includes('503') || msg.includes('high demand') || msg.includes('UNAVAILABLE')) {
msg = `Google 伺服器目前尖峰塞車 (503 High Demand),請稍候 10~20 秒後再試一次;若需要極高穩定度可至「⚙️ API 設定」填寫付費版金鑰。`;
} else if (msg.includes('QUOTA_EXCEEDED') || msg.includes('429')) {
msg = `API 額度已用完,請至右上角「⚙️ API 設定」填寫您的 API 金鑰。`;
}
outputArea.innerHTML = `❌ 標注失敗:${escapeHtml(msg)}
`;
btn.innerHTML = '🏷️ 語言學標注 (IGT)';
} finally {
btn.disabled = false;
}
};
// 把整句加入主辭典:管理員直接寫入;一般使用者改為送出建議,等管理員審核
async function addSentenceToDictionary(trukuSentence, chineseTranslation) {
if (!isAdmin) {
const note = prompt('(選填)想補充給審核者的說明:') || '';
await submitSuggestion({ kind: 'sentence', truku_sentence: trukuSentence, chinese_translation: chineseTranslation, note });
return '💡 已送出建議,管理員審核通過後才會加入主辭典。';
}
const res = await adminFetch('/api/dictionary/add_sentence', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ truku_sentence: trukuSentence, chinese_translation: chineseTranslation })
});
if (!res.ok) throw new Error(await readErrorMessage(res, '請稍後再試。'));
const data = await res.json().catch(() => ({}));
return data.status === 'exists' ? 'ℹ️ 此句與中文翻譯已在主辭典中,未重複新增。' : '✅ 已加入主辭典!';
}
// 若整句不在主辭典,詢問是否一併新增(或一併建議新增)
async function offerAddToDictionary(block) {
try {
const trukuSentence = block.querySelector('.igt-l1').innerText.trim();
const chineseTranslation = block.querySelector('.igt-l4').innerText.trim();
const checkRes = await fetch('/api/dictionary/check_sentence', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ truku_sentence: trukuSentence })
});
if (!checkRes.ok) return;
const checkData = await checkRes.json();
if (checkData.exists) return;
const action = isAdmin ? '新增到主辭典' : '建議把它加入主辭典';
if (!confirm(`發現這整句例句目前不在主辭典中:\n\n${trukuSentence}\n\n請問要順便${action}嗎?`)) return;
alert(await addSentenceToDictionary(trukuSentence, chineseTranslation));
} catch(e) {
alert(`❌ 送出失敗:${e.message}`);
}
}
// 逐詞校正:管理員直接存進黃金記憶庫;一般使用者送出建議(依區塊標注模式分開存放)
async function saveIgtBlockCorrection(block) {
const question = isAdmin
? "確定要將這些單字校正儲存至記憶庫嗎?\n這將做為未來 AI 自動標注的黃金標準!"
: "確定要送出這些單字校正建議嗎?\n管理員審核通過後,才會成為 AI 自動標注的黃金標準。";
if (!confirm(question)) return;
const saveMode = block.dataset.mode || glosserMode;
const words = Array.from(block.querySelectorAll('.igt-word-col')).map(col => ({
raw: col.dataset.raw,
morph: col.querySelector('.igt-l2').innerText.trim(),
gloss: col.querySelector('.igt-l3').innerText.trim()
}));
try {
if (isAdmin) {
const res = await adminFetch('/api/save_correct', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ words, annotation_mode: saveMode })
});
if (!res.ok) throw new Error(await readErrorMessage(res, '請稍後再試。'));
} else {
const note = prompt('(選填)想補充給審核者的說明:') || '';
await submitSuggestion({
kind: 'correction', words, annotation_mode: saveMode, note,
truku_sentence: block.querySelector('.igt-l1').innerText.trim(),
chinese_translation: block.querySelector('.igt-l4').innerText.trim()
});
}
} catch(e) {
alert(`❌ ${isAdmin ? '儲存' : '送出'}失敗:${e.message}`);
return;
}
const msgEl = block.querySelector('.igt-save-msg');
if (msgEl) {
msgEl.textContent = isAdmin ? '✅ 已儲存!' : '✅ 已送出建議!';
msgEl.style.display = 'inline';
setTimeout(() => { msgEl.style.display = 'none'; }, 3000);
}
alert(isAdmin ? "✅ 儲存成功!" : "💡 已送出建議,管理員審核通過後才會寫入記憶庫。");
await offerAddToDictionary(block);
}
window.igtSave = function(btn, sIdx) {
return saveIgtBlockCorrection(btn.closest('.igt-block'));
};
window.igtDownloadCsv = function(btn) {
const outputArea = btn.closest('.igt-output-area');
const sentences = outputArea._glosserData || [];
let csv = '\uFEFF句子編號,行一(原文),行二(詞素切分),行三(語法標記),行四(華語翻譯)\n';
sentences.forEach((s, i) => {
const blocks = btn.closest('.igt-container').querySelectorAll('.igt-block');
const block = blocks[i];
const cols = block ? block.querySelectorAll('.igt-word-col') : [];
const raws = s.words.map(w => w.raw).join(' ');
const morphs = Array.from(cols).map(c => c.querySelector('.igt-l2').innerText.trim()).join(' ');
const glosses = Array.from(cols).map(c => c.querySelector('.igt-l3').innerText.trim()).join(' ');
const trans = s.translation || '';
csv += `${i+1},"${raws.replace(/"/g,'""')}","${morphs.replace(/"/g,'""')}","${glosses.replace(/"/g,'""')}","${trans.replace(/"/g,'""')}"\n`;
});
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `igt_annotation_${new Date().toISOString().slice(0,10)}.csv`;
a.click();
};
window.igtDownloadWord = async function(btn, sIdx) {
const outputArea = btn.closest('.igt-output-area');
const sentences = outputArea._glosserData || [];
const blocks = btn.closest('.igt-container').querySelectorAll('.igt-block');
// Build up-to-date data from editable fields
const payload = { sentences: [] };
blocks.forEach((block, i) => {
const cols = block.querySelectorAll('.igt-word-col');
const words = Array.from(cols).map(col => ({
raw: col.dataset.raw,
morph: col.querySelector('.igt-l2').innerText.trim(),
gloss: col.querySelector('.igt-l3').innerText.trim(),
confidence: 'high'
}));
const trans = block.querySelector('.igt-l4').innerText.replace(/^'|'$/g, '');
payload.sentences.push({ words, translation: trans });
});
try {
const res = await fetch('/api/download_word', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const blob = await res.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = '太魯閣語IGT標注成果.docx';
a.click();
} catch(e) { alert('下載失敗,請確認標注後端已開啟。'); }
};
// AI Analysis Logic
document.addEventListener('DOMContentLoaded', () => {
const analyzeBtn = document.getElementById('aiAnalyzeBtn');
if (analyzeBtn) {
analyzeBtn.addEventListener('click', async () => {
const w1 = document.getElementById('aiWord1').value.trim();
const w2 = document.getElementById('aiWord2').value.trim();
const limit = document.getElementById('aiSentenceLimit').value;
currentAiWord1 = w1;
currentAiWord2 = w2;
if (!w1) {
alert('請至少輸入第一個詞彙進行分析!');
return;
}
const loading = document.getElementById('ai-loading');
const errorDiv = document.getElementById('ai-error');
const resultContainer = document.getElementById('ai-result-container');
const contentDiv = document.getElementById('ai-markdown-content');
const statsInfo = document.getElementById('ai-stats-info');
loading.classList.remove('hidden');
errorDiv.classList.add('hidden');
resultContainer.classList.add('hidden');
analyzeBtn.disabled = true;
try {
const params = new URLSearchParams({ w1, limit });
if (w2) params.append('w2', w2);
// 自訂金鑰放在請求標頭,不放進網址
const customApiKey = localStorage.getItem('custom_gemini_api_key') || "";
const headers = customApiKey ? { 'X-Gemini-Api-Key': customApiKey } : {};
const response = await fetch(`/api/ai_analysis?${params.toString()}`, { headers });
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || '分析發生錯誤');
}
if (w2) {
statsInfo.innerHTML = `基於 ${Number(data.w1_count) || 0} 句 ${escapeHtml(w1)} 與 ${Number(data.w2_count) || 0} 句 ${escapeHtml(w2)} 例句進行分析`;
} else {
statsInfo.innerHTML = `基於 ${Number(data.w1_count) || 0} 句 ${escapeHtml(w1)} 例句進行分析`;
}
// Parse markdown first, then strip anything executable before inserting
let htmlContent = sanitizeHtml(marked.parse(data.result || ''));
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// Highlight keywords in HTML (only outside tags)
if (w1) {
const regex1 = new RegExp('\\b(' + escapeRegExp(w1) + ')\\b(?![^<]*>)', 'gi');
htmlContent = htmlContent.replace(regex1, '$1');
}
if (w2) {
const regex2 = new RegExp('\\b(' + escapeRegExp(w2) + ')\\b(?![^<]*>)', 'gi');
htmlContent = htmlContent.replace(regex2, '$1');
}
contentDiv.innerHTML = htmlContent;
resultContainer.classList.remove('hidden');
} catch (err) {
errorDiv.textContent = err.message;
errorDiv.classList.remove('hidden');
} finally {
loading.classList.add('hidden');
analyzeBtn.disabled = false;
}
});
}
});
// AI Analysis Export Functions
function exportToDoc() {
const contentHtml = document.getElementById('ai-markdown-content').innerHTML;
const header = `太魯閣語語言學分析報告`;
const footer = ``;
const sourceHTML = header + contentHtml + footer;
let filename = '太魯閣語語言學分析報告_' + currentAiWord1;
if (currentAiWord2) filename += '&' + currentAiWord2;
filename += '.doc';
const source = 'data:application/vnd.ms-word;charset=utf-8,' + encodeURIComponent(sourceHTML);
const fileDownload = document.createElement("a");
document.body.appendChild(fileDownload);
fileDownload.href = source;
fileDownload.download = filename;
fileDownload.click();
document.body.removeChild(fileDownload);
}
function exportToPdf() {
const element = document.getElementById('ai-markdown-content');
// Temporarily set text color to black for PDF rendering
const originalColor = element.style.color;
element.style.color = '#000000';
// Add a class to adjust typography for print
element.classList.add('pdf-exporting');
let filename = '太魯閣語語言學分析報告_' + currentAiWord1;
if (currentAiWord2) filename += '&' + currentAiWord2;
filename += '.pdf';
const opt = {
margin: 0.6,
filename: filename,
image: { type: 'jpeg', quality: 1.0 },
html2canvas: { scale: 4, useCORS: true, logging: false },
jsPDF: { unit: 'in', format: 'a4', orientation: 'portrait' },
pagebreak: { mode: ['css', 'avoid-all'] }
};
html2pdf().set(opt).from(element).save().then(() => {
// Revert text color back to original after PDF is generated
element.style.color = originalColor;
element.classList.remove('pdf-exporting');
});
}
// =========================================================
// 🏷️ 語料標注頁籤 (Tab Glosser) — Full standalone logic
// =========================================================
let glosserSessionData = []; // Stores all sentences from last batch run
// -- 1. Status Checker: polls server every 5s to show green/red dot --
async function checkGlosserStatus() {
const dot = document.getElementById('glosser-status-dot');
const text = document.getElementById('glosser-status-text');
if (!dot || !text) return;
try {
const res = await fetch('/', { signal: AbortSignal.timeout(2000) });
if (res.ok) {
dot.style.background = '#4ade80';
text.style.color = '#4ade80';
text.textContent = '標注引擎就緒 (雲端連線正常)';
} else { throw new Error(); }
} catch {
dot.style.background = '#f87171';
text.style.color = '#f87171';
text.textContent = '標注引擎離線';
}
}
// -- 2. Render IGT 4-line block into #glosser-results --
function renderGlosserIGT(sentences, mode) {
const usedMode = mode || glosserMode;
const modeBadge = usedMode === 'semantic'
? `🔤 語意標注`
: `🔬 構詞語法標注`;
const container = document.getElementById('glosser-results');
container.innerHTML = '';
glosserSessionData = sentences;
sentences.forEach((sentObj, sIdx) => {
const words = sentObj.words || [];
const translation = sentObj.translation || '';
const originalLine = words.map(w => w.raw).join(' ');
let groups = [];
words.forEach((w, wIdx) => {
const m = (w.morph || w.raw || '').trim();
if (m.startsWith('=') && groups.length > 0) {
groups[groups.length - 1].push({w, wIdx});
} else {
groups.push([{w, wIdx}]);
}
});
const wordColsHtml = groups.map(group => {
const cols = group.map(item => {
const w = item.w;
const wIdx = item.wIdx;
const isLow = w.confidence === 'low';
return `
${escapeHtml(w.morph || w.raw || '')}
${escapeHtml(w.gloss || '')}
`;
}).join('');
return `${cols}
`;
}).join('');
const block = document.createElement('div');
block.className = 'igt-block';
block.dataset.sidx = sIdx;
block.dataset.mode = usedMode;
block.innerHTML = `
句子成果 #${sIdx + 1}${modeBadge}
${escapeHtml(originalLine)}
${wordColsHtml}
${escapeHtml(translation)}
✅ 已儲存!
`;
container.appendChild(block);
});
}
// -- 3. Analyze button handler --
document.addEventListener('DOMContentLoaded', () => {
// Start status polling as soon as page loads
checkGlosserStatus();
setInterval(checkGlosserStatus, 6000);
const btn = document.getElementById('glosserAnalyzeBtn');
if (!btn) return;
btn.addEventListener('click', async () => {
let trukuText = (document.getElementById('glosser-truku-input').value || '').trim();
let chineseText = (document.getElementById('glosser-chinese-input').value || '').trim();
if (!trukuText) { alert('請先輸入太魯閣語語料!'); return; }
const loading = document.getElementById('glosser-loading');
const errorDiv = document.getElementById('glosser-error');
const timer = document.getElementById('glosser-timer');
const results = document.getElementById('glosser-results');
const batchBar = document.getElementById('glosser-batch-bar');
loading.classList.remove('hidden');
errorDiv.classList.add('hidden');
timer.classList.add('hidden');
results.innerHTML = '';
batchBar.classList.add('hidden');
btn.disabled = true;
// Smart sentence splitting (same as original glosser)
trukuText = trukuText.replace(/([.?])\s+/g, '$1\n').replace(/\n+/g, '\n');
chineseText = chineseText.replace(/([。?!])\s*/g, '$1\n').replace(/\n+/g, '\n');
const startTime = performance.now();
try {
const customApiKey = localStorage.getItem('custom_gemini_api_key') || "";
const res = await fetch('/api/parse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ truku_sentence: trukuText, chinese_translation: chineseText, custom_api_key: customApiKey, annotation_mode: glosserMode })
});
if (res.status === 429) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.detail === 'RATE_LIMITED' ? errData.message : 'QUOTA_EXCEEDED');
}
if (res.status === 503) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.message || 'SERVICE_BUSY');
}
if (!res.ok) {
let errText = await res.text();
try {
const parsed = JSON.parse(errText);
if (parsed.message) errText = parsed.message;
else if (parsed.detail) errText = parsed.detail;
} catch(ign) {}
throw new Error(errText);
}
const data = await res.json();
const elapsed = ((performance.now() - startTime) / 1000).toFixed(2);
const sentences = data.sentences || [];
if (sentences.length === 0) throw new Error('AI 未回傳任何標注結果。');
const usedMode = data.annotation_mode || glosserMode;
const modeLabel = usedMode === 'semantic' ? '🔤 語意標注' : '🔬 構詞語法標注';
timer.classList.remove('hidden');
timer.innerHTML = `⏱️ 批次標注完成(${modeLabel})!共處理 ${sentences.length} 個句子,核心運算耗時 ${elapsed} 秒。`;
renderGlosserIGT(sentences, usedMode);
batchBar.classList.remove('hidden');
} catch(e) {
errorDiv.classList.remove('hidden');
if (e.message.includes('QUOTA_EXCEEDED')) {
errorDiv.innerHTML = `❌ 系統公用額度已用完!
請通知作者,或點擊右上角「⚙️ API 設定」填寫您個人的 Google Gemini API Key 後繼續使用。`;
} else if (e.message.includes('SERVICE_BUSY') || e.message.includes('503') || e.message.includes('high demand') || e.message.includes('UNAVAILABLE')) {
errorDiv.innerHTML = `⚠️ Google 伺服器目前尖峰塞車 (503 High Demand)
這是 Google 官方端暫時性算力滿載(免費版 Key 優先級較低時常發生)。
👉 請稍等 10~20 秒後重新點擊「送出標注」;若需大量穩定標注,更換為付費版 (Pay-as-you-go) 金鑰可徹底避免此問題。`;
} else {
errorDiv.innerHTML = `❌ 標注失敗:${escapeHtml(e.message)}
請檢查您的 API Key 是否有效,或稍後再試。`;
}
} finally {
loading.classList.add('hidden');
btn.disabled = false;
}
});
});
// -- 4. Save one sentence to memory DB --
window.glosserSaveSentence = function(btn, sIdx) {
return saveIgtBlockCorrection(btn.closest('.igt-block'));
};
window.importToDictSentence = async function(btn) {
const question = isAdmin
? "確定要將這句話與意譯「匯入主辭典」作為標準例句嗎?"
: "確定要建議把這句話與意譯加入主辭典嗎?\n管理員審核通過後才會加入。";
if (!confirm(question)) return;
const block = btn.closest('.igt-block');
const trukuSentence = block.querySelector('.igt-l1').innerText.trim();
const chineseTranslation = block.querySelector('.igt-l4').innerText.trim();
btn.textContent = isAdmin ? '匯入中...' : '送出中...';
btn.disabled = true;
try {
const message = await addSentenceToDictionary(trukuSentence, chineseTranslation);
btn.textContent = isAdmin ? '✅ 完成' : '✅ 已送出建議';
alert(message);
} catch(e) {
btn.textContent = '❌ 失敗';
alert(`❌ ${isAdmin ? '匯入' : '送出'}失敗:${e.message}`);
}
setTimeout(() => { btn.textContent = igtImportLabel(); btn.disabled = false; }, 3000);
};
// -- 5. Batch CSV export from the glosser tab --
window.glosserBatchCsv = function() {
const blocks = document.querySelectorAll('#glosser-results .igt-block');
if (!blocks.length) return;
let csv = '\uFEFF句子編號,行一(原文),行二(詞素切分),行三(語法標記),行四(華語翻譯)\n';
blocks.forEach((block, i) => {
const l1 = block.querySelector('.igt-l1').innerText.trim();
const cols = block.querySelectorAll('.igt-word-col');
const morphs = Array.from(cols).map(c => c.querySelector('.igt-l2').innerText.trim()).join(' ');
const glosses = Array.from(cols).map(c => c.querySelector('.igt-l3').innerText.trim()).join(' ');
const l4 = block.querySelector('.igt-l4').innerText.replace(/^'|'$/g,'').trim();
csv += `${i+1},"${l1.replace(/"/g,'""')}","${morphs.replace(/"/g,'""')}","${glosses.replace(/"/g,'""')}","${l4.replace(/"/g,'""')}"\n`;
});
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `太魯閣語IGT標注_${new Date().toISOString().slice(0,10)}.csv`;
a.click();
};
// -- 6. Batch Word export from the glosser tab --
window.glosserBatchWord = async function() {
const blocks = document.querySelectorAll('#glosser-results .igt-block');
if (!blocks.length) return;
const payload = { sentences: [] };
blocks.forEach(block => {
const cols = block.querySelectorAll('.igt-word-col');
const words = Array.from(cols).map(col => ({
raw: col.dataset.raw,
morph: col.querySelector('.igt-l2').innerText.trim(),
gloss: col.querySelector('.igt-l3').innerText.trim(),
confidence: 'high'
}));
const trans = block.querySelector('.igt-l4').innerText.replace(/^'|'$/g,'').trim();
payload.sentences.push({ words, translation: trans });
});
try {
const res = await fetch('/api/download_word', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const blob = await res.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `太魯閣語IGT標注成果_${new Date().toISOString().slice(0,10)}.docx`;
a.click();
} catch(e) { alert('下載失敗,請確認標注後端已開啟。'); }
};
// -- 7. Bridge: fill glosser tab from a search result sentence --
window.sendToGlosser = function(trukuSentence, chineseTranslation) {
// Switch to glosser tab — 透過頁籤按鈕切換,與一般切換共用同一套 .active 樣式
// (原本改用 .hidden 切換,CSS 只認 .active,會造成畫面空白且其他頁籤無法再顯示)
document.querySelector('[data-tab="tab-glosser"]').click();
// Pre-fill input fields
document.getElementById('glosser-truku-input').value = trukuSentence;
document.getElementById('glosser-chinese-input').value = chineseTranslation || '';
// Scroll up and auto-click analyze
document.getElementById('glosser-truku-input').scrollIntoView({ behavior: 'smooth' });
setTimeout(() => { document.getElementById('glosserAnalyzeBtn').click(); }, 400);
};
// =========================================================
// ☀️ Theme Toggle Logic (Light / Dark Mode)
// =========================================================
document.addEventListener('DOMContentLoaded', () => {
const themeToggleBtn = document.getElementById('themeToggleBtn');
if (!themeToggleBtn) return;
// Default to dark mode unless 'light' is saved
const savedTheme = localStorage.getItem('truku-dict-theme');
if (savedTheme === 'light') {
document.documentElement.setAttribute('data-theme', 'light');
themeToggleBtn.innerHTML = '🌙 暗色模式';
}
themeToggleBtn.addEventListener('click', () => {
const currentTheme = document.documentElement.getAttribute('data-theme');
if (currentTheme === 'light') {
document.documentElement.removeAttribute('data-theme'); // reverts to dark (default)
localStorage.setItem('truku-dict-theme', 'dark');
themeToggleBtn.innerHTML = '☀️ 亮色模式';
} else {
document.documentElement.setAttribute('data-theme', 'light');
localStorage.setItem('truku-dict-theme', 'light');
themeToggleBtn.innerHTML = '🌙 暗色模式';
}
});
});
// =========================================================
// 📁 Workspace / Projects Logic
// =========================================================
let currentActiveProjectId = null;
let currentProjectSentences = [];
let batchAbortController = null;
async function loadProjectsList() {
const container = document.getElementById('projects-list-container');
if (!container) return;
try {
const resp = await adminFetch('/api/projects');
const data = await resp.json();
const projects = data.projects || [];
if (projects.length === 0) {
container.innerHTML = `
尚無任何專案
`;
return;
}
container.innerHTML = projects.map(p => `
${escapeHtml(p.name)}
共 ${p.total_count} 句
已完成 ${p.done_count}
`).join('');
} catch (e) {
container.innerHTML = `載入失敗: ${escapeHtml(e.message)}
`;
}
}
window.openCreateProjectModal = async function() {
const name = prompt('請輸入新專案名稱:\n(例如:第一冊第八課、賽德克故事選集)');
if (!name || !name.trim()) return;
const description = prompt('請輸入專案描述(選填):') || '';
try {
const resp = await adminFetch('/api/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name.trim(), description: description.trim() })
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.message || data.error || '建立失敗');
await loadProjectsList();
selectProject(data.id);
} catch (e) {
alert(`❌ 建立失敗: ${e.message}`);
}
};
window.selectProject = async function(pid) {
currentActiveProjectId = pid;
const emptyState = document.getElementById('project-empty-state');
const activeWorkbench = document.getElementById('project-active-workbench');
if (emptyState) emptyState.classList.add('hidden');
if (activeWorkbench) activeWorkbench.classList.remove('hidden');
loadProjectsList();
try {
const resp = await adminFetch(`/api/projects/${pid}`);
if (!resp.ok) throw new Error('找不到此專案');
const data = await resp.json();
const p = data.project;
currentProjectSentences = data.sentences || [];
document.getElementById('current-proj-name').textContent = p.name;
document.getElementById('current-proj-desc').textContent = p.description || '無描述';
const total = currentProjectSentences.length;
const done = currentProjectSentences.filter(s => s.status === 'done').length;
const pending = currentProjectSentences.filter(s => s.status === 'pending').length;
document.getElementById('proj-stats-badge').innerHTML = `總計 ${total} 句 | 已完成 ${done} | 待標注 ${pending}`;
renderProjectSentences(currentProjectSentences);
} catch (e) {
alert(`❌ 載入專案失敗: ${e.message}`);
}
};
function renderProjectSentences(sentences) {
const container = document.getElementById('project-sentences-container');
if (!container) return;
if (sentences.length === 0) {
container.innerHTML = `
此專案尚無任何語料。
請上方點擊「📁 選擇檔案並匯入」上傳 Excel 或 TXT 檔案。
`;
return;
}
container.innerHTML = sentences.map((s, idx) => {
const igt = s.igt_result;
const words = (igt && igt.words) ? igt.words : [];
let statusBadge = `待標注`;
if (s.status === 'done') {
statusBadge = `✅ 標注完成`;
} else if (s.status === 'error') {
statusBadge = `❌ 錯誤`;
}
let wordsHtml = '';
if (words.length > 0) {
wordsHtml = `
${words.map((w, wIdx) => `
${escapeHtml(w.raw || '')}
${escapeHtml(w.morph || '')}
${escapeHtml(w.gloss || '')}
`).join('')}
`;
}
return `
#${idx + 1}
${statusBadge}
${escapeHtml(s.truku_sentence)}
${escapeHtml(s.chinese_translation || '(無中文對照)')}
${wordsHtml}
`;
}).join('');
}
window.updateSentenceWord = async function(sid, wIdx, field, newText) {
const s = currentProjectSentences.find(item => item.id === sid);
if (!s || !s.igt_result || !s.igt_result.words) return;
s.igt_result.words[wIdx][field] = newText.trim();
try {
const resp = await adminFetch(`/api/projects/${currentActiveProjectId}/sentences/${sid}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ igt_result: s.igt_result })
});
if (!resp.ok) alert(`❌ 更新失敗:${await readErrorMessage(resp, '請稍後再試。')}`);
} catch (e) {
console.error('更新單句失敗:', e);
}
};
window.handleProjectFileSelect = async function(event) {
const file = event.target.files[0];
if (!file || !currentActiveProjectId) return;
const formData = new FormData();
formData.append('file', file);
try {
const resp = await adminFetch(`/api/projects/${currentActiveProjectId}/import`, {
method: 'POST',
body: formData
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.message || data.error || '匯入失敗');
alert(`✅ ${data.message}`);
selectProject(currentActiveProjectId);
} catch (e) {
alert(`❌ 匯入失敗: ${e.message}`);
}
};
window.startBatchAnnotation = async function() {
if (!currentActiveProjectId) return;
const customApiKey = localStorage.getItem('custom_gemini_api_key') || "";
const progressContainer = document.getElementById('batch-progress-container');
const progressBar = document.getElementById('batch-progress-bar');
const progressText = document.getElementById('batch-progress-text');
const progressPct = document.getElementById('batch-progress-pct');
const startBtn = document.getElementById('start-batch-btn');
if (progressContainer) progressContainer.classList.remove('hidden');
if (startBtn) startBtn.disabled = true;
// 改用 POST 串流(EventSource 只能 GET,金鑰會被放進網址)
const pid = currentActiveProjectId;
if (batchAbortController) batchAbortController.abort();
batchAbortController = new AbortController();
const handleBatchEvent = function(data) {
if (data.event === 'progress') {
const pct = data.progress;
if (progressBar) progressBar.style.width = `${pct}%`;
if (progressPct) progressPct.textContent = `${pct}%`;
if (progressText) progressText.textContent = `[${data.current}/${data.total}] 正在標注:${data.truku_sentence}`;
const s = currentProjectSentences.find(item => item.id === data.sentence_id);
if (s) {
s.status = data.status;
s.igt_result = data.igt_result;
renderProjectSentences(currentProjectSentences);
}
} else if (data.event === 'completed') {
if (progressBar) progressBar.style.width = '100%';
if (progressPct) progressPct.textContent = '100%';
if (progressText) progressText.textContent = `✅ 批次標注完成!`;
}
};
try {
const resp = await adminFetch(`/api/projects/${pid}/run_batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ custom_api_key: customApiKey }),
signal: batchAbortController.signal
});
if (!resp.ok) throw new Error(await readErrorMessage(resp, '批次標注啟動失敗'));
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let sep;
while ((sep = buffer.indexOf('\n\n')) >= 0) {
const chunk = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
const line = chunk.split('\n').find(l => l.startsWith('data: '));
if (line) handleBatchEvent(JSON.parse(line.slice(6)));
}
}
} catch (err) {
if (err.name === 'AbortError') return;
if (progressText) progressText.textContent = `⚠️ 批次標注中斷:${err.message},將重新載入專案...`;
} finally {
if (startBtn) startBtn.disabled = false;
}
if (currentActiveProjectId === pid) selectProject(pid);
};
window.exportCurrentProject = function(fmt) {
if (!currentActiveProjectId) return;
// 匯出需要管理員憑證,無法用一般連結下載,改用 fetch 取得檔案後再存檔
adminFetch(`/api/projects/${currentActiveProjectId}/export?format=${encodeURIComponent(fmt)}`)
.then(async res => {
if (!res.ok) throw new Error(await readErrorMessage(res, '匯出失敗'));
const blob = await res.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `專案匯出_IGT.${fmt === 'csv' ? 'csv' : 'docx'}`;
a.click();
URL.revokeObjectURL(a.href);
})
.catch(e => alert(`❌ ${e.message}`));
};
window.deleteCurrentProject = async function() {
if (!currentActiveProjectId) return;
if (!confirm('⚠️ 確定要刪除整個專案及其所有句子嗎?此動作無法復原!')) return;
try {
const resp = await adminFetch(`/api/projects/${currentActiveProjectId}`, { method: 'DELETE' });
if (!resp.ok) throw new Error(await readErrorMessage(resp, '刪除失敗'));
currentActiveProjectId = null;
document.getElementById('project-active-workbench').classList.add('hidden');
document.getElementById('project-empty-state').classList.remove('hidden');
loadProjectsList();
} catch (e) {
alert(`❌ 刪除失敗: ${e.message}`);
}
};
// ═══════════════════════════════════════════════════
// 📊 使用量事件記錄工具函式
// ═══════════════════════════════════════════════════
/**
* 非阻塞式事件記錄 — fire-and-forget
* @param {string} eventType 'search' | 'tts' | 'igt_parse' | 'project_batch'
* @param {object} eventData 附帶資料(關鍵字、語者等)
*/
window.logEvent = function(eventType, eventData = {}) {
fetch('/api/log_event', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ event_type: eventType, event_data: eventData })
}).catch(() => {}); // 靜默失敗,不影響主功能
};
// ═══════════════════════════════════════════════════
// 📊 使用量儀表板載入 & 渲染
// ═══════════════════════════════════════════════════
let usageTrendChart = null;
let usageFeatureChart = null;
let usageSearchTypeChart = null;
let usageTtsSpeakerChart = null;
async function loadUsageDashboard() {
try {
const resp = await adminFetch('/api/dashboard');
if (!resp.ok) throw new Error('API failed');
const d = await resp.json();
// ── 數字卡片 ──
document.getElementById('usage-total-search').textContent = (d.total_search || 0).toLocaleString();
document.getElementById('usage-total-tts').textContent = (d.total_tts || 0).toLocaleString();
document.getElementById('usage-total-igt').textContent = (d.total_igt || 0).toLocaleString();
document.getElementById('usage-total-batch').textContent = (d.total_batch || 0).toLocaleString();
// ── 7日趨勢折線圖 ──
const trendCtx = document.getElementById('usageTrendChart');
if (trendCtx) {
if (usageTrendChart) usageTrendChart.destroy();
usageTrendChart = new Chart(trendCtx.getContext('2d'), {
type: 'line',
data: {
labels: d.daily_trend.map(r => r.date.slice(5)), // MM-DD
datasets: [{
label: '每日搜尋次數',
data: d.daily_trend.map(r => r.count),
borderColor: '#38bdf8',
backgroundColor: 'rgba(56,189,248,0.15)',
borderWidth: 2,
pointBackgroundColor: '#38bdf8',
pointRadius: 4,
fill: true,
tension: 0.4
}]
},
options: {
responsive: true,
plugins: { legend: { display: false } },
scales: {
x: { ticks: { color: '#94a3b8' }, grid: { color: 'rgba(255,255,255,0.05)' } },
y: { ticks: { color: '#94a3b8', stepSize: 1 }, grid: { color: 'rgba(255,255,255,0.05)' }, min: 0 }
}
}
});
}
// ── 功能使用環形圖 ──
const featureCtx = document.getElementById('usageFeatureChart');
if (featureCtx) {
if (usageFeatureChart) usageFeatureChart.destroy();
const featureLabels = { search: '搜尋', tts: 'TTS語音', igt_parse: 'IGT標注', project_batch: '批次標注' };
const fKeys = Object.keys(d.totals);
const fVals = fKeys.map(k => d.totals[k]);
const fColors = ['#38bdf8', '#34d399', '#a78bfa', '#fb923c'];
usageFeatureChart = new Chart(featureCtx.getContext('2d'), {
type: 'doughnut',
data: {
labels: fKeys.map(k => featureLabels[k] || k),
datasets: [{ data: fVals, backgroundColor: fColors, borderWidth: 0 }]
},
options: {
responsive: true,
plugins: {
legend: { position: 'bottom', labels: { color: '#e2e8f0', boxWidth: 12 } }
}
}
});
}
// ── 搜尋類型橫條圖 ──
const stCtx = document.getElementById('usageSearchTypeChart');
if (stCtx) {
if (usageSearchTypeChart) usageSearchTypeChart.destroy();
const stLabelMap = { truku: '族語搜尋', chinese: '中文搜尋', source: '來源詞搜尋', unknown: '未知' };
const stKeys = Object.keys(d.search_type_dist);
const stVals = stKeys.map(k => d.search_type_dist[k]);
usageSearchTypeChart = new Chart(stCtx.getContext('2d'), {
type: 'bar',
data: {
labels: stKeys.map(k => stLabelMap[k] || k),
datasets: [{
label: '次數',
data: stVals,
backgroundColor: ['#60a5fa', '#a78bfa', '#34d399', '#94a3b8'],
borderRadius: 6
}]
},
options: {
responsive: true,
indexAxis: 'y',
plugins: { legend: { display: false } },
scales: {
x: { ticks: { color: '#94a3b8' }, grid: { color: 'rgba(255,255,255,0.05)' }, min: 0 },
y: { ticks: { color: '#e2e8f0' }, grid: { display: false } }
}
}
});
}
// ── TTS語者環形圖 ──
const spCtx = document.getElementById('usageTtsSpeakerChart');
if (spCtx) {
if (usageTtsSpeakerChart) usageTtsSpeakerChart.destroy();
const spLabelMap = { female: '女聲', male1: '男聲1', male2: '男聲2', unknown: '未知' };
const spKeys = Object.keys(d.tts_speaker_dist);
const spVals = spKeys.map(k => d.tts_speaker_dist[k]);
usageTtsSpeakerChart = new Chart(spCtx.getContext('2d'), {
type: 'doughnut',
data: {
labels: spKeys.map(k => spLabelMap[k] || k),
datasets: [{ data: spVals, backgroundColor: ['#f472b6', '#60a5fa', '#34d399', '#94a3b8'], borderWidth: 0 }]
},
options: {
responsive: true,
plugins: {
legend: { position: 'bottom', labels: { color: '#e2e8f0', boxWidth: 12 } }
}
}
});
}
// ── TOP 20 熱門搜尋詞 ──
const topEl = document.getElementById('usage-top-queries');
if (topEl) {
if (!d.top_queries || d.top_queries.length === 0) {
topEl.innerHTML = '尚無搜尋記錄。請先在「辭典查詢」頁籤搜尋幾個詞彙後再回來查看。
';
} else {
const maxCnt = d.top_queries[0].count;
topEl.innerHTML = d.top_queries.map((item, idx) => {
const pct = Math.round((item.count / maxCnt) * 100);
const medal = idx === 0 ? '🥇' : idx === 1 ? '🥈' : idx === 2 ? '🥉' : `${idx + 1}.`;
return `
${medal}
${escapeHtml(item.query)}
${item.count}
`;
}).join('');
}
}
} catch (e) {
console.warn('[Dashboard] 載入失敗:', e.message);
}
}
// ── 在 initSubTabs 中監聽 subtab-usage 點擊,自動重新載入儀表板 ──
document.addEventListener('DOMContentLoaded', () => {
document.addEventListener('click', (e) => {
const btn = e.target.closest('.sub-tab-btn');
if (btn && btn.dataset.subtab === 'subtab-usage') {
loadUsageDashboard();
}
});
});
// =========================================================
// 📥 建議審核頁(管理員登入後才顯示)
// 使用者送出的逐詞校正與例句建議,要管理員按「核准」才會真正寫入
// =========================================================
let reviewSection = 'suggestions';
let reviewStatus = 'pending';
let reviewItems = [];
const FEEDBACK_TYPE_LABELS = { system_bug: '系統功能異常', sentence_error: '例句內容錯誤', suggestion: '功能建議' };
const REVIEW_FILTER_LABELS = {
'suggestions:pending': '待審核建議', 'suggestions:approved': '已核准', 'suggestions:rejected': '已退回',
'feedback:pending': '未處理回報', 'feedback:handled': '已處理回報'
};
// SQLite 的 CURRENT_TIMESTAMP 是 UTC,顯示時轉成使用者當地時間
function formatUtcTimestamp(ts) {
if (!ts) return '';
const d = new Date(String(ts).replace(' ', 'T') + 'Z');
return isNaN(d) ? String(ts) : d.toLocaleString('zh-TW', { hour12: false });
}
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.review-filter-btn').forEach(btn => {
btn.addEventListener('click', () => {
reviewSection = btn.dataset.section;
reviewStatus = btn.dataset.status;
document.querySelectorAll('.review-filter-btn').forEach(b => b.classList.toggle('active', b === btn));
loadReviewTab();
});
});
});
// ── 資料保存狀態(Storage Bucket 是否掛載、最後備份時間、錯誤訊息) ──
function formatAgo(epochSeconds, serverNow) {
if (!epochSeconds) return '';
const sec = Math.max(0, Math.round((serverNow || Date.now() / 1000) - epochSeconds));
if (sec < 60) return `${sec} 秒前`;
if (sec < 3600) return `${Math.round(sec / 60)} 分鐘前`;
return new Date(epochSeconds * 1000).toLocaleString('zh-TW', { hour12: false });
}
function renderStorageStatus(s, message) {
const box = document.getElementById('storage-status');
if (!box) return;
let level, headline, detail = '';
if (!s.mount_exists) {
level = 'bad';
headline = '⚠️ 沒有偵測到 Storage Bucket:Space 重啟後,建議、回報、黃金記憶庫校正都會消失';
detail = '請到這個 Space 的 Settings →「Storage Buckets」掛載 Bucket,掛載路徑填 /data。';
} else if (s.last_error) {
level = 'bad';
headline = '⚠️ 已偵測到 Bucket,但備份失敗';
detail = `錯誤訊息:${s.last_error}`;
} else if (s.backup_exists) {
level = 'ok';
headline = `✅ 資料保存正常:最後備份 ${formatAgo(s.backup_mtime, s.server_time)}`;
detail = `備份位置:${s.backup_file}(${(s.backup_size / 1024).toFixed(1)} KB)`;
} else {
level = 'warn';
headline = '⏳ 已偵測到 Bucket,尚未產生備份';
detail = '有人送出建議、回報或審核後約 30 秒會自動備份;也可以按「立即備份」。';
}
const color = { ok: '#10b981', warn: '#f59e0b', bad: '#ef4444' }[level];
box.style.borderColor = color;
box.innerHTML = `
💾 ${escapeHtml(headline)}
${escapeHtml(detail)}
本站:${escapeHtml(s.site_env)}|啟動時:${escapeHtml(s.restore || '—')}${s.mount_exists && !s.mount_writable ? '|⚠️ 掛載點看起來是唯讀' : ''}
${message ? `
${escapeHtml(message)}
` : ''}
`;
}
async function loadStorageStatus() {
try {
const res = await adminFetch('/api/admin/storage_status');
if (res.ok) renderStorageStatus(await res.json());
} catch (e) { console.error(e); }
}
window.snapshotNow = async function(btn) {
btn.disabled = true;
btn.textContent = '備份中...';
try {
const res = await adminFetch('/api/admin/storage_snapshot', { method: 'POST' });
const data = await res.json().catch(() => ({}));
if (data.status) renderStorageStatus(data.status, data.message);
else alert(data.message || '備份失敗');
} catch (e) {
alert(`❌ ${e.message}`);
btn.disabled = false;
btn.textContent = '💾 立即備份';
}
};
async function loadReviewTab() {
const list = document.getElementById('review-list');
if (!list) return;
loadStorageStatus();
list.innerHTML = '載入中...
';
try {
const sStatus = reviewSection === 'suggestions' ? reviewStatus : 'pending';
const fStatus = reviewSection === 'feedback' ? reviewStatus : 'pending';
const [sRes, fRes] = await Promise.all([
adminFetch(`/api/admin/suggestions?status=${encodeURIComponent(sStatus)}`),
adminFetch(`/api/admin/feedback?status=${encodeURIComponent(fStatus)}`)
]);
if (!sRes.ok || !fRes.ok) {
const failed = sRes.ok ? fRes : sRes;
const msg = await readErrorMessage(failed, '載入失敗');
if (failed.status === 403) await refreshAdminState();
throw new Error(msg);
}
const sData = await sRes.json();
const fData = await fRes.json();
updateReviewCounts(sData.counts || {}, fData.counts || {});
reviewItems = reviewSection === 'suggestions' ? (sData.suggestions || []) : (fData.feedback || []);
if (!reviewItems.length) {
list.innerHTML = '目前沒有資料。
';
return;
}
list.innerHTML = reviewItems.map(item =>
reviewSection === 'suggestions' ? renderSuggestionCard(item) : renderFeedbackCard(item)
).join('');
} catch (e) {
list.innerHTML = `❌ ${escapeHtml(e.message)}
`;
}
}
function updateReviewCounts(sCounts, fCounts) {
document.querySelectorAll('.review-filter-btn').forEach(btn => {
const counts = btn.dataset.section === 'suggestions' ? sCounts : fCounts;
const label = REVIEW_FILTER_LABELS[`${btn.dataset.section}:${btn.dataset.status}`];
btn.textContent = `${label} (${counts[btn.dataset.status] || 0})`;
});
}
function renderSuggestionCard(item) {
const p = item.payload || {};
const id = Number(item.id) || 0;
const pending = item.status === 'pending';
const edit = pending ? 'class="review-editable" contenteditable="true"' : '';
let body;
if (item.kind === 'correction') {
const modeLabel = p.annotation_mode === 'semantic' ? '🔤 語意標注' : '🔬 構詞語法標注';
const rows = (p.words || []).map(w => `
| ${escapeHtml(w.raw)} |
${escapeHtml(w.morph)} |
${escapeHtml(w.gloss)} |
`).join('');
body = `
${escapeHtml(p.truku_sentence || '')}
${escapeHtml(p.chinese_translation || '')}
| 詞 | 詞素切分 / ${modeLabel} | 標記 |
${rows}
`;
} else {
body = `
族語句子
${escapeHtml(p.truku_sentence)}
中文翻譯
${escapeHtml(p.chinese_translation)}
`;
}
const kindLabel = item.kind === 'correction' ? '✏️ 逐詞校正(寫入黃金記憶庫)' : '📖 建議加入主辭典';
const note = item.note
? `💬 使用者說明:${escapeHtml(item.note)}
` : '';
const reviewed = !pending
? `審核時間:${escapeHtml(formatUtcTimestamp(item.reviewed_at))}${item.review_note ? `|審核備註:${escapeHtml(item.review_note)}` : ''}
` : '';
const actions = pending ? `
虛線內容可直接點擊修改後再核准
` : '';
return `
#${id}|${kindLabel}|送出時間:${escapeHtml(formatUtcTimestamp(item.created_at))}
${body}${note}${reviewed}${actions}
`;
}
function renderFeedbackCard(item) {
const id = Number(item.id) || 0;
const typeLabel = FEEDBACK_TYPE_LABELS[item.type] || item.type || '其他';
const sentence = item.sentence_text
? `📌 相關例句(ID ${Number(item.sentence_id) || '-'}):${escapeHtml(item.sentence_text)}
` : '';
const email = item.email
? `聯絡信箱:${escapeHtml(item.email)}
` : '';
const action = item.status === 'pending'
? ``
: ``;
return `
#${id}|${escapeHtml(typeLabel)}|回報時間:${escapeHtml(formatUtcTimestamp(item.timestamp))}
${escapeHtml(item.description)}
${sentence}${email}
${action}
`;
}
window.approveSuggestion = async function(btn, id) {
const item = reviewItems.find(it => it.id === id);
const card = btn.closest('.review-card');
if (!item || !card) return;
const fieldText = (root, field) => (root.querySelector(`[data-field="${field}"]`)?.innerText || '').trim();
let payload;
if (item.kind === 'correction') {
payload = {
...item.payload,
words: Array.from(card.querySelectorAll('tr[data-raw]')).map(tr => ({
raw: tr.dataset.raw, morph: fieldText(tr, 'morph'), gloss: fieldText(tr, 'gloss')
}))
};
} else {
payload = { truku_sentence: fieldText(card, 'truku_sentence'), chinese_translation: fieldText(card, 'chinese_translation') };
}
const target = item.kind === 'correction' ? '黃金記憶庫' : '主辭典';
if (!confirm(`確定核准並寫入${target}嗎?`)) return;
btn.disabled = true;
try {
const res = await adminFetch(`/api/admin/suggestions/${id}/approve`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ payload })
});
if (!res.ok) throw new Error(await readErrorMessage(res, '核准失敗'));
const data = await res.json();
alert(`✅ ${data.message}`);
} catch (e) {
alert(`❌ ${e.message}`);
}
loadReviewTab();
};
window.rejectSuggestion = async function(btn, id) {
const note = prompt('退回原因(選填,只有管理員看得到):');
if (note === null) return;
btn.disabled = true;
try {
const res = await adminFetch(`/api/admin/suggestions/${id}/reject`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ review_note: note })
});
if (!res.ok) throw new Error(await readErrorMessage(res, '退回失敗'));
} catch (e) {
alert(`❌ ${e.message}`);
}
loadReviewTab();
};
window.setFeedbackStatus = async function(btn, id, status) {
btn.disabled = true;
try {
const res = await adminFetch(`/api/admin/feedback/${id}/status`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status })
});
if (!res.ok) throw new Error(await readErrorMessage(res, '更新失敗'));
} catch (e) {
alert(`❌ ${e.message}`);
}
loadReviewTab();
};