feat: 阶段2 Python后端、用户管理、统计、单词收藏与响应式适配

This commit is contained in:
guoqingshan
2026-08-01 17:46:04 +08:00
parent a2e705aa30
commit 55705683ff
26 changed files with 1929 additions and 42 deletions
+233 -36
View File
@@ -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);