feat: 阶段2 Python后端、用户管理、统计、单词收藏与响应式适配
This commit is contained in:
@@ -5,6 +5,34 @@ const dbAddEntry = addEntry;
|
||||
const dbUpdateEntry = updateEntry;
|
||||
const dbDeleteEntry = deleteEntry;
|
||||
|
||||
var BACKEND_MODE = false;
|
||||
var currentUser = null;
|
||||
var favoriteWords = {};
|
||||
|
||||
function safeGetAllEntries() {
|
||||
if (BACKEND_MODE) return apiGetEntries();
|
||||
return dbGetAllEntries();
|
||||
}
|
||||
|
||||
function safeAddEntry(entry) {
|
||||
if (BACKEND_MODE) return apiAddEntry(entry);
|
||||
return dbAddEntry(entry);
|
||||
}
|
||||
|
||||
function safeUpdateEntry(id, entry) {
|
||||
if (BACKEND_MODE) return apiUpdateEntry(id, entry);
|
||||
return dbGetEntry(id).then(function(existing) {
|
||||
Object.assign(existing, entry);
|
||||
existing.updatedAt = new Date().toISOString();
|
||||
return dbUpdateEntry(existing);
|
||||
});
|
||||
}
|
||||
|
||||
function safeDeleteEntry(id) {
|
||||
if (BACKEND_MODE) return apiDeleteEntry(id);
|
||||
return dbDeleteEntry(id);
|
||||
}
|
||||
|
||||
var savedSelStart = 0;
|
||||
var savedSelEnd = 0;
|
||||
|
||||
@@ -100,6 +128,10 @@ function updateVocabulary(text) {
|
||||
return;
|
||||
}
|
||||
container.innerHTML = vocab.map(function(v) {
|
||||
var isFavorite = favoriteWords[v.word.toLowerCase()] ? 'active' : '';
|
||||
var favBtn = BACKEND_MODE
|
||||
? '<button class="favorite-btn ' + isFavorite + '" data-word="' + escHtml(v.word) + '" data-en="' + escHtml(v.english) + '" data-zh="' + escHtml(v.chinese) + '" title="收藏单词">★</button>'
|
||||
: '';
|
||||
return '<div class="vocab-item">' +
|
||||
'<span class="vocab-word">' + escHtml(v.word) + '</span>' +
|
||||
'<button class="vocab-speech" data-word="' + escHtml(v.word) + '" data-lang="es">' + '\uD83D\uDD0A' + '</button>' +
|
||||
@@ -107,6 +139,7 @@ function updateVocabulary(text) {
|
||||
'<button class="vocab-speech" data-word="' + escHtml(v.word) + '" data-lang="en">🔊</button>' +
|
||||
'<span class="vocab-zh">' + escHtml(v.chinese) + '</span>' +
|
||||
'<button class="vocab-speech" data-word="' + escHtml(v.word) + '" data-lang="zh">🔊</button>' +
|
||||
favBtn +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
@@ -138,12 +171,13 @@ function handleSelection() {
|
||||
|
||||
function selectEntry(id) {
|
||||
state.currentId = id;
|
||||
dbGetEntry(id).then(function(entry) {
|
||||
if (entry) {
|
||||
setEditor(entry);
|
||||
renderTranslate(entry);
|
||||
renderList();
|
||||
}
|
||||
var entry = state.entries.find(function(e) { return e.id == id; });
|
||||
var p = entry ? Promise.resolve(entry) : dbGetEntry(id);
|
||||
p.then(function(found) {
|
||||
if (!found) return;
|
||||
setEditor(found);
|
||||
renderTranslate(found);
|
||||
renderList();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -153,16 +187,18 @@ function saveCurrentEntry() {
|
||||
if (!spanish) { alert('请输入西语内容'); return; }
|
||||
|
||||
var translation = translateText(spanish);
|
||||
|
||||
var data = {
|
||||
date: date,
|
||||
spanish: spanish,
|
||||
english: translation.english,
|
||||
chinese: translation.chinese
|
||||
};
|
||||
var savePromise;
|
||||
if (state.currentId) {
|
||||
savePromise = dbGetEntry(state.currentId).then(function(existing) {
|
||||
existing.date = date;
|
||||
existing.spanish = spanish;
|
||||
existing.english = translation.english;
|
||||
existing.chinese = translation.chinese;
|
||||
existing.updatedAt = new Date().toISOString();
|
||||
return dbUpdateEntry(existing);
|
||||
savePromise = safeUpdateEntry(state.currentId, data);
|
||||
} else if (BACKEND_MODE) {
|
||||
savePromise = apiAddEntry(data).then(function(entry) {
|
||||
state.currentId = entry.id;
|
||||
});
|
||||
} else {
|
||||
var entry = {
|
||||
@@ -180,7 +216,7 @@ function saveCurrentEntry() {
|
||||
}
|
||||
|
||||
savePromise.then(function() {
|
||||
return dbGetAllEntries();
|
||||
return safeGetAllEntries();
|
||||
}).then(function(entries) {
|
||||
state.entries = entries;
|
||||
renderList();
|
||||
@@ -213,9 +249,9 @@ function hideDeleteModal() {
|
||||
}
|
||||
|
||||
function confirmDelete(id) {
|
||||
dbDeleteEntry(id).then(function() {
|
||||
safeDeleteEntry(id).then(function() {
|
||||
if (state.currentId === id) clearEditor();
|
||||
return dbGetAllEntries();
|
||||
return safeGetAllEntries();
|
||||
}).then(function(entries) {
|
||||
state.entries = entries;
|
||||
renderList();
|
||||
@@ -338,16 +374,142 @@ function handleSpeech(lang) {
|
||||
if (text && text !== '-') speakText(text, lang);
|
||||
}
|
||||
|
||||
// ----- Initialization -----
|
||||
// ----- Backend features -----
|
||||
|
||||
function init() {
|
||||
dbGetAllEntries().then(function(entries) {
|
||||
function renderUserArea() {
|
||||
if (!currentUser) return;
|
||||
document.getElementById('user-name').textContent = currentUser.displayName || currentUser.username;
|
||||
document.getElementById('btn-logout').style.display = '';
|
||||
if (currentUser.isAdmin) {
|
||||
document.getElementById('admin-link').style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
function loadBackendData() {
|
||||
safeGetAllEntries().then(function(entries) {
|
||||
state.entries = entries;
|
||||
renderList();
|
||||
setEditor(null);
|
||||
renderTranslate(null);
|
||||
});
|
||||
loadStats();
|
||||
loadFavoriteWords();
|
||||
loadSettingsForm();
|
||||
}
|
||||
|
||||
function loadLocalData() {
|
||||
safeGetAllEntries().then(function(entries) {
|
||||
state.entries = entries;
|
||||
renderList();
|
||||
setEditor(null);
|
||||
renderTranslate(null);
|
||||
});
|
||||
}
|
||||
|
||||
function loadStats() {
|
||||
if (!BACKEND_MODE) {
|
||||
document.getElementById('stats-content').textContent = '登录后显示统计';
|
||||
return;
|
||||
}
|
||||
apiGetStats().then(function(stats) {
|
||||
var dailyHtml = (stats.daily || []).map(function(d) {
|
||||
return d.date + ': ' + d.count + ' 篇';
|
||||
}).join(' · ');
|
||||
document.getElementById('stats-content').innerHTML =
|
||||
'<div class="stat-grid">' +
|
||||
'<div class="stat-item"><div class="stat-value">' + stats.total + '</div><div class="stat-label">总日记</div></div>' +
|
||||
'<div class="stat-item"><div class="stat-value">' + stats.streak + '</div><div class="stat-label">连续天数</div></div>' +
|
||||
'<div class="stat-item"><div class="stat-value">' + stats.week + '</div><div class="stat-label">本周</div></div>' +
|
||||
'<div class="stat-item"><div class="stat-value">' + stats.month + '</div><div class="stat-label">本月</div></div>' +
|
||||
'</div>' +
|
||||
'<div class="daily-stats">最近 7 天:' + escHtml(dailyHtml) + '</div>' +
|
||||
'<div class="daily-stats">累计西语字符:' + stats.totalChars + '</div>';
|
||||
}).catch(function() {});
|
||||
}
|
||||
|
||||
function loadFavoriteWords() {
|
||||
if (!BACKEND_MODE) return;
|
||||
apiGetVocabulary().then(function(items) {
|
||||
favoriteWords = {};
|
||||
items.forEach(function(item) {
|
||||
favoriteWords[item.word.toLowerCase()] = item;
|
||||
});
|
||||
updateLiveTranslation();
|
||||
}).catch(function() {});
|
||||
}
|
||||
|
||||
function toggleFavorite(btn) {
|
||||
var word = btn.dataset.word;
|
||||
var key = word.toLowerCase();
|
||||
if (favoriteWords[key]) {
|
||||
apiDeleteVocabulary(favoriteWords[key].id).then(function() {
|
||||
delete favoriteWords[key];
|
||||
btn.classList.remove('active');
|
||||
}).catch(function(err) { alert(err.message); });
|
||||
} else {
|
||||
apiAddVocabulary({
|
||||
word: word,
|
||||
english: btn.dataset.en || '',
|
||||
chinese: btn.dataset.zh || '',
|
||||
category: ''
|
||||
}).then(function(item) {
|
||||
favoriteWords[key] = item;
|
||||
btn.classList.add('active');
|
||||
}).catch(function(err) { alert(err.message); });
|
||||
}
|
||||
}
|
||||
|
||||
function loadSettingsForm() {
|
||||
if (!BACKEND_MODE) return;
|
||||
apiGetTranslationSettings().then(function(settings) {
|
||||
document.getElementById('translator-type').value = settings.translator || 'local';
|
||||
document.getElementById('api-url').value = settings.api_url || '';
|
||||
document.getElementById('api-key').value = settings.api_key || '';
|
||||
document.getElementById('extra-config').value = JSON.stringify(settings.extra_config || {}, null, 2);
|
||||
}).catch(function() {});
|
||||
}
|
||||
|
||||
function openSettingsModal() {
|
||||
document.getElementById('settings-modal-overlay').classList.add('show');
|
||||
}
|
||||
|
||||
function closeSettingsModal() {
|
||||
document.getElementById('settings-modal-overlay').classList.remove('show');
|
||||
}
|
||||
|
||||
function saveSettingsModal() {
|
||||
var extra = {};
|
||||
var rawExtra = document.getElementById('extra-config').value.trim();
|
||||
if (rawExtra) {
|
||||
try {
|
||||
extra = JSON.parse(rawExtra);
|
||||
} catch (e) {
|
||||
alert('扩展配置必须是合法 JSON');
|
||||
return;
|
||||
}
|
||||
}
|
||||
apiSaveTranslationSettings({
|
||||
translator: document.getElementById('translator-type').value,
|
||||
api_url: document.getElementById('api-url').value.trim(),
|
||||
api_key: document.getElementById('api-key').value.trim(),
|
||||
extra_config: extra
|
||||
}).then(function() {
|
||||
closeSettingsModal();
|
||||
alert('翻译设置已保存');
|
||||
}).catch(function(err) { alert(err.message); });
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
apiLogout().then(function() {
|
||||
location.href = 'login.html';
|
||||
}).catch(function() {
|
||||
location.href = 'login.html';
|
||||
});
|
||||
}
|
||||
|
||||
// ----- Initialization -----
|
||||
|
||||
function bindAppEvents() {
|
||||
document.getElementById('entry-list').addEventListener('click', function(e) {
|
||||
var li = e.target.closest('li[data-id]');
|
||||
if (!li) return;
|
||||
@@ -358,31 +520,42 @@ function init() {
|
||||
selectEntry(li.dataset.id);
|
||||
});
|
||||
|
||||
document.getElementById('btn-new').addEventListener('click', newEntry);
|
||||
document.getElementById('btn-save').addEventListener('click', saveCurrentEntry);
|
||||
document.getElementById('btn-helper').addEventListener('click', openHelperModal);
|
||||
document.getElementById('helper-cancel').addEventListener('click', closeHelperModal);
|
||||
document.getElementById('helper-confirm').addEventListener('click', confirmHelperInsert);
|
||||
document.getElementById('helper-input').addEventListener('input', updateHelperPreview);
|
||||
document.getElementById('btn-new').addEventListener('click', newEntry);
|
||||
document.getElementById('btn-save').addEventListener('click', saveCurrentEntry);
|
||||
document.getElementById('btn-helper').addEventListener('click', openHelperModal);
|
||||
document.getElementById('helper-cancel').addEventListener('click', closeHelperModal);
|
||||
document.getElementById('helper-confirm').addEventListener('click', confirmHelperInsert);
|
||||
document.getElementById('helper-input').addEventListener('input', updateHelperPreview);
|
||||
|
||||
document.querySelectorAll('.btn-speech').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() { handleSpeech(btn.dataset.lang); });
|
||||
});
|
||||
document.getElementById('btn-settings').addEventListener('click', openSettingsModal);
|
||||
document.getElementById('settings-cancel').addEventListener('click', closeSettingsModal);
|
||||
document.getElementById('settings-save').addEventListener('click', saveSettingsModal);
|
||||
document.getElementById('btn-logout').addEventListener('click', handleLogout);
|
||||
|
||||
document.querySelectorAll('#mobile-nav button').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
document.body.dataset.view = btn.dataset.view;
|
||||
document.querySelectorAll('#mobile-nav button').forEach(function(b) {
|
||||
b.classList.toggle('active', b === btn);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.btn-speech').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() { handleSpeech(btn.dataset.lang); });
|
||||
});
|
||||
|
||||
// Live translation on input
|
||||
document.getElementById('entry-text').addEventListener('input', updateLiveTranslation);
|
||||
|
||||
// Selection translation
|
||||
document.getElementById('entry-text').addEventListener('mouseup', function(e) { handleSelection(); saveTextareaSelection(); });
|
||||
document.getElementById('entry-text').addEventListener('keyup', function(e) { handleSelection(); saveTextareaSelection(); });
|
||||
|
||||
// Vocabulary speech delegation
|
||||
document.getElementById('editor-vocab').addEventListener('click', function(e) {
|
||||
var fav = e.target.closest('.favorite-btn');
|
||||
if (fav) { toggleFavorite(fav); return; }
|
||||
var btn = e.target.closest('.vocab-speech');
|
||||
if (btn) handleVocabSpeech(btn.dataset.word, btn.dataset.lang);
|
||||
});
|
||||
|
||||
// Vocabulary double-click to auto-read
|
||||
document.getElementById('editor-vocab').addEventListener('dblclick', function(e) {
|
||||
var item = e.target.closest('.vocab-item');
|
||||
if (!item) return;
|
||||
@@ -397,7 +570,6 @@ function init() {
|
||||
}
|
||||
});
|
||||
|
||||
// Textarea double-click auto-read
|
||||
document.getElementById('entry-text').addEventListener('dblclick', function() {
|
||||
var ta = document.getElementById('entry-text');
|
||||
setTimeout(function() {
|
||||
@@ -406,7 +578,6 @@ function init() {
|
||||
}, 50);
|
||||
});
|
||||
|
||||
// Speak selected button - reads selection aloud
|
||||
document.getElementById('btn-speak-selected').addEventListener('click', function() {
|
||||
var ta = document.getElementById('entry-text');
|
||||
var start = savedSelStart || ta.selectionStart;
|
||||
@@ -430,4 +601,30 @@ function init() {
|
||||
});
|
||||
}
|
||||
|
||||
function startApp() {
|
||||
if (location.protocol === 'file:') {
|
||||
BACKEND_MODE = false;
|
||||
loadLocalData();
|
||||
return;
|
||||
}
|
||||
apiGetMe().then(function(me) {
|
||||
BACKEND_MODE = true;
|
||||
currentUser = me;
|
||||
renderUserArea();
|
||||
loadBackendData();
|
||||
}).catch(function(err) {
|
||||
if (err && err.status === 401) {
|
||||
location.href = 'login.html';
|
||||
return;
|
||||
}
|
||||
BACKEND_MODE = false;
|
||||
loadLocalData();
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
bindAppEvents();
|
||||
startApp();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
|
||||
Reference in New Issue
Block a user