feat: 重构移动端布局并新增专用单词本页面

This commit is contained in:
guoqingshan
2026-08-02 11:00:39 +08:00
parent 870f8a5da3
commit b337ee23c2
8 changed files with 867 additions and 48 deletions
+2
View File
@@ -8,6 +8,7 @@
- 🌐 实时翻译:输入西语自动显示英语和中文对照
- 🔊 发音朗读:支持西语、英语、中文三种语言(Web Speech API)
- 📖 单词本:自动提取输入中的关键词并给出解释,支持点击发音
- 📕 专用单词本页面:支持搜索、分类筛选、添加、编辑、删除和三语朗读
- 📚 语法知识库:按教材整理的 40 个语音和语法知识点
## 使用
@@ -84,4 +85,5 @@ server/data/spanish_diary.db
- LibreTranslate 等自定义接口默认使用 `zh-hans` 表示简体中文,可通过扩展配置 `zh_code` 覆盖
- 每用户日记统计
- 每用户单词收藏
- 移动端左上菜单、编辑区优先、左滑显示翻译
- 手机、平板、桌面响应式适配
+4
View File
@@ -72,6 +72,10 @@ function apiAddVocabulary(data) {
return apiFetch('/api/vocabulary', { method: 'POST', body: data });
}
function apiUpdateVocabulary(id, data) {
return apiFetch('/api/vocabulary/' + id, { method: 'PUT', body: data });
}
function apiDeleteVocabulary(id) {
return apiFetch('/api/vocabulary/' + id, { method: 'DELETE' });
}
+152 -15
View File
@@ -8,6 +8,7 @@ const dbDeleteEntry = deleteEntry;
var BACKEND_MODE = false;
var currentUser = null;
var favoriteWords = {};
var LOCAL_WB_KEY = 'spanish_diary_wordbook';
var currentTranslationSettings = { translator: 'local' };
var liveTranslationTimer = null;
var liveTranslationSeq = 0;
@@ -181,9 +182,6 @@ function updateVocabulary(text) {
}
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>' +
@@ -191,7 +189,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 +
'<button class="favorite-btn ' + isFavorite + '" data-word="' + escHtml(v.word) + '" data-en="' + escHtml(v.english) + '" data-zh="' + escHtml(v.chinese) + '" title="收藏单词">★</button>' +
'</div>';
}).join('');
}
@@ -252,6 +250,7 @@ function selectEntry(id) {
setEditor(found);
renderTranslate(found);
renderList();
showEditorView();
});
}
@@ -312,13 +311,48 @@ function saveCurrentEntry() {
function newEntry() {
clearEditor();
document.getElementById('entry-date').value = todayStr();
showEditorView();
document.getElementById('entry-text').focus();
}
function showDiaryListView() {
document.body.dataset.view = 'list';
document.querySelectorAll('#mobile-nav button').forEach(function(btn) {
btn.classList.toggle('active', btn.dataset.view === 'list');
closeMobileMenu();
}
function showEditorView() {
document.body.dataset.view = 'editor';
closeMobileMenu();
}
function showTranslationView() {
document.body.dataset.view = 'translate';
closeMobileMenu();
}
function hideTranslationView() {
document.body.dataset.view = 'editor';
}
function showStatsView() {
document.body.dataset.view = 'stats';
closeMobileMenu();
}
function openMobileMenu() {
refreshMobileMenuAuth();
document.getElementById('mobile-menu-overlay').classList.add('show');
}
function closeMobileMenu() {
document.getElementById('mobile-menu-overlay').classList.remove('show');
}
function refreshMobileMenuAuth() {
var loggedIn = !!currentUser;
['password', 'settings', 'logout'].forEach(function(action) {
var el = document.querySelector('#mobile-menu [data-menu="' + action + '"]');
if (el) el.style.display = loggedIn ? '' : 'none';
});
}
@@ -507,6 +541,7 @@ function renderUserArea() {
if (currentUser.isAdmin) {
document.getElementById('admin-link').style.display = '';
}
refreshMobileMenuAuth();
}
function loadBackendData() {
@@ -522,6 +557,7 @@ function loadBackendData() {
}
function loadLocalData() {
loadLocalFavoriteWords();
safeGetAllEntries().then(function(entries) {
state.entries = entries;
renderList();
@@ -530,6 +566,25 @@ function loadLocalData() {
});
}
function getLocalWordbook() {
try {
return JSON.parse(localStorage.getItem(LOCAL_WB_KEY) || '[]');
} catch (e) {
return [];
}
}
function saveLocalWordbook(items) {
localStorage.setItem(LOCAL_WB_KEY, JSON.stringify(items));
}
function loadLocalFavoriteWords() {
favoriteWords = {};
getLocalWordbook().forEach(function(item) {
favoriteWords[String(item.word || '').toLowerCase()] = item;
});
}
function loadStats() {
if (!BACKEND_MODE) {
document.getElementById('stats-content').textContent = '登录后显示统计';
@@ -565,6 +620,30 @@ function loadFavoriteWords() {
function toggleFavorite(btn) {
var word = btn.dataset.word;
var key = word.toLowerCase();
if (!BACKEND_MODE) {
var localItems = getLocalWordbook();
if (favoriteWords[key]) {
localItems = localItems.filter(function(item) {
return String(item.word || '').toLowerCase() !== key;
});
delete favoriteWords[key];
btn.classList.remove('active');
} else {
var localItem = {
id: 'local-' + Date.now(),
word: word,
english: btn.dataset.en || '',
chinese: btn.dataset.zh || '',
category: '',
created_at: new Date().toISOString()
};
localItems.unshift(localItem);
favoriteWords[key] = localItem;
btn.classList.add('active');
}
saveLocalWordbook(localItems);
return;
}
if (favoriteWords[key]) {
apiDeleteVocabulary(favoriteWords[key].id).then(function() {
delete favoriteWords[key];
@@ -688,6 +767,40 @@ function bindAppEvents() {
});
document.getElementById('btn-new').addEventListener('click', newEntry);
document.getElementById('btn-back-list').addEventListener('click', showEditorView);
document.getElementById('menu-btn').addEventListener('click', openMobileMenu);
document.getElementById('translate-btn').addEventListener('click', function() {
if (document.body.dataset.view === 'translate') {
hideTranslationView();
} else {
showTranslationView();
}
});
document.getElementById('mobile-menu-overlay').addEventListener('click', function(e) {
if (e.target === this) closeMobileMenu();
});
document.getElementById('mobile-menu').addEventListener('click', function(e) {
var item = e.target.closest('[data-menu]');
if (!item) return;
var action = item.dataset.menu;
if (action === 'diary') {
showDiaryListView();
} else if (action === 'stats') {
showStatsView();
} else if (action === 'password') {
closeMobileMenu();
openPasswordModal();
} else if (action === 'settings') {
closeMobileMenu();
openSettingsModal();
} else if (action === 'logout') {
closeMobileMenu();
handleLogout();
} else {
closeMobileMenu();
}
});
document.getElementById('btn-save').addEventListener('click', saveCurrentEntry);
document.getElementById('btn-helper').addEventListener('click', openHelperModal);
document.getElementById('helper-cancel').addEventListener('click', closeHelperModal);
@@ -702,15 +815,6 @@ function bindAppEvents() {
document.getElementById('password-save').addEventListener('click', savePasswordModal);
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); });
});
@@ -769,6 +873,39 @@ function bindAppEvents() {
document.addEventListener('keydown', function(e) {
if (e.ctrlKey && e.key === 'Enter') saveCurrentEntry();
});
var swipeStartX = null;
var swipeStartY = null;
var swipeActive = false;
var appElement = document.getElementById('app');
appElement.addEventListener('touchstart', function(e) {
if (e.touches.length !== 1) return;
swipeStartX = e.touches[0].clientX;
swipeStartY = e.touches[0].clientY;
swipeActive = true;
}, { passive: true });
appElement.addEventListener('touchmove', function(e) {
if (!swipeActive || e.touches.length !== 1) return;
var dx = e.touches[0].clientX - swipeStartX;
var dy = e.touches[0].clientY - swipeStartY;
if (Math.abs(dx) > 30 && Math.abs(dx) > Math.abs(dy)) {
e.preventDefault();
}
}, { passive: false });
appElement.addEventListener('touchend', function(e) {
if (!swipeActive) return;
var dx = e.changedTouches[0].clientX - swipeStartX;
var dy = e.changedTouches[0].clientY - swipeStartY;
var view = document.body.dataset.view;
if (dx < -55 && Math.abs(dx) > Math.abs(dy) * 1.2 && view === 'editor') {
showTranslationView();
} else if (dx > 55 && Math.abs(dx) > Math.abs(dy) * 1.2 && (view === 'translate' || view === 'stats')) {
hideTranslationView();
}
swipeStartX = null;
swipeStartY = null;
swipeActive = false;
});
}
function startApp() {
+20 -7
View File
@@ -6,17 +6,29 @@
<title>Mi Diario — 西班牙语日记</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<nav id="mobile-nav">
<button data-view="list" class="active">📋 日记</button>
<button data-view="editor">✏️ 编辑</button>
<button data-view="translate">🌐 翻译</button>
<button data-view="mine">👤 我的</button>
</nav>
<body data-view="editor">
<header id="mobile-header">
<button id="menu-btn" title="菜单">☰</button>
<span id="mobile-title">Mi Diario</span>
<button id="translate-btn" title="翻译">🌐</button>
</header>
<div id="mobile-menu-overlay">
<div id="mobile-menu">
<div class="mobile-menu-head">Mi Diario</div>
<button data-menu="diary">📋 日记</button>
<a data-menu="wordbook" href="wordbook.html">📖 单词本</a>
<a data-menu="knowledge" href="knowledge.html">📚 语法速查</a>
<button data-menu="stats">📊 我的统计</button>
<button data-menu="password">🔑 修改密码</button>
<button data-menu="settings">⚙️ 设置</button>
<button data-menu="logout">🚪 退出</button>
</div>
</div>
<div id="app">
<aside id="panel-list">
<div class="panel-header">
<button id="btn-back-list" title="返回编辑">←</button>
<h2>📋 日记</h2>
<button id="btn-new" title="新建日记">+</button>
</div>
@@ -41,6 +53,7 @@
<div id="user-area">
<span id="user-name">未登录</span>
<a id="wordbook-link" href="wordbook.html">单词本</a>
<button id="btn-settings" title="翻译设置">⚙️ 设置</button>
<a id="admin-link" href="admin.html" style="display:none">用户管理</a>
<button id="btn-change-password" style="display:none" title="修改密码">🔑 修改密码</button>
+51
View File
@@ -72,6 +72,57 @@ def add_vocabulary(user):
conn.close()
@vocab_bp.put("/api/vocabulary/<int:item_id>")
@login_required
def update_vocabulary(user, item_id):
data = request.get_json(silent=True) or {}
word = (data.get("word") or "").strip()
if not word:
return jsonify({"error": "单词不能为空"}), 400
conn = get_db()
try:
row = conn.execute(
"SELECT id FROM vocabulary WHERE id = ? AND user_id = ?",
(item_id, user["id"]),
).fetchone()
if row is None:
return jsonify({"error": "收藏不存在或无权访问"}), 404
duplicate = conn.execute(
"""
SELECT id FROM vocabulary
WHERE user_id = ? AND word = ? AND id <> ?
""",
(user["id"], word, item_id),
).fetchone()
if duplicate:
return jsonify({"error": "该单词已在单词本中"}), 409
conn.execute(
"""
UPDATE vocabulary
SET word = ?, english = ?, chinese = ?, category = ?
WHERE id = ?
""",
(
word,
(data.get("english") or "").strip(),
(data.get("chinese") or "").strip(),
(data.get("category") or "").strip(),
item_id,
),
)
conn.commit()
updated = conn.execute(
"""
SELECT id, word, english, chinese, category, created_at
FROM vocabulary WHERE id = ?
""",
(item_id,),
).fetchone()
return jsonify(row_to_dict(updated))
finally:
conn.close()
@vocab_bp.delete("/api/vocabulary/<int:item_id>")
@login_required
def delete_vocabulary(user, item_id):
+127 -26
View File
@@ -43,6 +43,8 @@ body {
}
#mobile-nav { display: none; }
#mobile-header, #mobile-menu-overlay { display: none; }
#btn-back-list { display: none; }
/* User area and stats */
#user-area {
@@ -373,32 +375,131 @@ body {
#password-modal-box .btn-confirm { background: var(--accent); color: #fff; border-color: var(--accent); }
@media (max-width: 768px) {
body { padding: 0; }
#mobile-nav {
display: flex; position: sticky; top: 0; z-index: 80;
background: var(--panel-bg); border-bottom: 1px solid var(--border);
padding: 0.4rem;
}
#mobile-nav button {
flex: 1; min-height: 40px; background: none; border: 1px solid var(--border);
border-radius: 6px; font-size: 0.85rem; cursor: pointer; color: var(--text-muted);
}
#mobile-nav button.active { background: var(--accent); color: #fff; border-color: var(--accent); }
#app { grid-template-columns: 1fr; gap: 0; padding: 0.5rem; }
#panel-list { max-height: calc(100vh - 60px); }
#panel-editor { min-height: calc(100vh - 60px); }
#panel-translate { max-height: calc(100vh - 60px); }
body { padding: 0; overflow-x: hidden; }
#panel-editor, #panel-translate, #stats-panel { display: none; }
body[data-view="editor"] #panel-editor { display: flex; }
body[data-view="editor"] #panel-list, body[data-view="editor"] #panel-translate { display: none; }
body[data-view="translate"] #panel-translate { display: flex; }
#mobile-header {
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 85;
height: 52px;
padding: 0 0.6rem;
background: var(--panel-bg);
border-bottom: 1px solid var(--border);
}
#mobile-header button {
width: 42px;
height: 42px;
border: none;
background: none;
font-size: 1.3rem;
cursor: pointer;
color: var(--text);
}
#mobile-title { font-weight: 700; font-size: 0.95rem; }
#mobile-menu-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.35);
z-index: 100;
}
#mobile-menu-overlay.show { display: block; }
#mobile-menu {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: min(300px, 84vw);
background: var(--panel-bg);
box-shadow: var(--shadow);
padding: 0.75rem;
overflow-y: auto;
}
.mobile-menu-head {
font-weight: 700;
font-size: 1.05rem;
padding: 0.5rem 0.5rem 1rem;
}
#mobile-menu button, #mobile-menu a {
display: flex;
width: 100%;
align-items: center;
gap: 0.6rem;
padding: 0.8rem 0.5rem;
border: none;
border-bottom: 1px solid var(--border);
background: none;
color: var(--text);
font-size: 0.95rem;
text-decoration: none;
cursor: pointer;
}
#app { display: block; padding: 0; }
#panel-list, #panel-translate, #stats-panel { display: none; }
#panel-editor {
display: flex;
min-height: calc(100vh - 52px);
border: none;
border-radius: 0;
}
#panel-list {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 70;
border: none;
border-radius: 0;
max-height: none;
}
body[data-view="list"] #panel-list { display: flex; }
#btn-back-list {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border: 1px solid var(--border);
border-radius: 6px;
background: none;
color: var(--text);
font-size: 1rem;
cursor: pointer;
}
#panel-list #entry-list { max-height: none; }
#panel-translate {
position: fixed;
top: 52px;
right: 0;
width: min(420px, 94vw);
height: calc(100vh - 52px);
z-index: 60;
border: none;
border-radius: 0;
transform: translateX(100%);
transition: transform 0.25s ease;
pointer-events: none;
}
body[data-view="translate"] #panel-translate,
body[data-view="stats"] #panel-translate {
display: flex;
transform: translateX(0);
pointer-events: auto;
}
body[data-view="translate"] #stats-panel { display: flex; }
body[data-view="translate"] #panel-list, body[data-view="translate"] #panel-editor { display: none; }
body[data-view="mine"] #panel-list { display: flex; }
body[data-view="mine"] #panel-translate { display: flex; }
body[data-view="mine"] #stats-panel { display: flex; }
body[data-view="mine"] #panel-translate .panel-header,
body[data-view="mine"] #panel-translate .translate-section { display: none; }
body[data-view="mine"] #entry-list, body[data-view="mine"] #grammar-quick { display: none; }
body[data-view="stats"] #panel-translate > .panel-header,
body[data-view="stats"] #panel-translate > .translate-section { display: none; }
body[data-view="stats"] #stats-panel {
display: flex;
max-height: none;
flex: 1;
}
}
+268
View File
@@ -0,0 +1,268 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>单词本 — Mi Diario</title>
<link rel="stylesheet" href="style.css">
<style>
body {
display: block;
max-width: 980px;
margin: 0 auto;
padding: 1rem;
}
.wb-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 1rem;
}
.wb-top h1 { font-size: 1.35rem; }
#back-home {
color: var(--accent);
text-decoration: none;
font-size: 0.9rem;
white-space: nowrap;
}
.wb-card {
background: var(--panel-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem;
margin-bottom: 1rem;
box-shadow: var(--shadow);
}
.wb-card h2 { font-size: 1rem; margin-bottom: 0.75rem; }
.wb-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.6rem;
}
.wb-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.8rem;
color: var(--text-muted);
}
.wb-form input, .wb-form select {
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.5rem 0.6rem;
background: var(--bg);
color: var(--text);
font-size: 0.9rem;
outline: none;
}
.wb-form input:focus, .wb-form select:focus { border-color: var(--accent); }
.wb-form button {
align-self: end;
background: var(--accent);
color: #fff;
border: none;
border-radius: 6px;
padding: 0.55rem 1rem;
cursor: pointer;
font-size: 0.9rem;
}
.wb-toolbar {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 0.6rem;
}
.wb-toolbar input, .wb-toolbar select {
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.5rem 0.6rem;
background: var(--bg);
color: var(--text);
font-size: 0.85rem;
outline: none;
}
.wb-meta {
display: flex;
gap: 1rem;
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
#wb-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0.75rem; }
.word-card {
background: var(--panel-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.85rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.word-main { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; }
.word-text {
font-size: 1.1rem;
font-weight: 700;
font-family: var(--font-mono);
color: var(--accent);
overflow-wrap: anywhere;
}
.word-cat {
font-size: 0.7rem;
color: var(--text-muted);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.15rem 0.5rem;
white-space: nowrap;
}
.word-langs { display: grid; gap: 0.3rem; font-size: 0.85rem; }
.lang-line { display: flex; align-items: center; gap: 0.4rem; }
.lang-label { width: 2rem; color: var(--text-muted); flex-shrink: 0; }
.lang-value { flex: 1; overflow-wrap: anywhere; }
.speak-word {
background: none;
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.2rem 0.45rem;
cursor: pointer;
color: var(--text-muted);
line-height: 1;
}
.speak-word:hover { border-color: var(--accent); color: var(--accent); }
.word-actions { display: flex; gap: 0.4rem; justify-content: flex-end; }
.word-actions button {
background: none;
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.3rem 0.55rem;
font-size: 0.8rem;
cursor: pointer;
color: var(--text-muted);
}
.word-actions button:hover { border-color: var(--accent); color: var(--accent); }
.word-actions .danger { color: var(--danger); border-color: var(--danger); }
.empty-state {
grid-column: 1 / -1;
text-align: center;
color: var(--text-muted);
padding: 2rem 0;
font-size: 0.9rem;
}
#wb-msg { min-height: 1.4em; font-size: 0.85rem; color: var(--text-muted); margin-bottom: 0.5rem; }
#edit-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.4);
align-items: center;
justify-content: center;
z-index: 200;
}
#edit-overlay.show { display: flex; }
#edit-box {
background: var(--panel-bg);
border-radius: var(--radius);
padding: 1.5rem;
width: min(480px, 92vw);
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
}
#edit-box h3 { font-size: 1rem; margin-bottom: 0.75rem; }
#edit-box label {
display: block;
font-size: 0.8rem;
color: var(--text-muted);
margin: 0.6rem 0 0.25rem;
}
#edit-box input {
width: 100%;
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.5rem 0.6rem;
background: var(--bg);
color: var(--text);
font-size: 0.9rem;
outline: none;
}
#edit-box input:focus { border-color: var(--accent); }
#edit-box .modal-actions { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1rem; }
#edit-box button {
border: 1px solid var(--border);
background: var(--panel-bg);
color: var(--text);
border-radius: 6px;
padding: 0.45rem 1rem;
cursor: pointer;
font-size: 0.85rem;
}
#edit-box .btn-confirm { background: var(--accent); color: #fff; border-color: var(--accent); }
@media (max-width: 640px) {
.wb-toolbar { grid-template-columns: 1fr; }
#wb-list { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="wb-top">
<h1>📖 单词本</h1>
<a id="back-home" href="index.html">← 返回日记</a>
</div>
<div id="wb-msg"></div>
<div class="wb-card">
<h2>添加单词</h2>
<div class="wb-form">
<label>西语<input id="add-word" type="text"></label>
<label>英语<input id="add-english" type="text"></label>
<label>中文<input id="add-chinese" type="text"></label>
<label>分类<input id="add-category" type="text" list="category-options" placeholder="名词 / 动词 / 短语"></label>
<button id="btn-add-word">添加</button>
</div>
<datalist id="category-options">
<option value="名词"></option>
<option value="动词"></option>
<option value="形容词"></option>
<option value="副词"></option>
<option value="短语"></option>
<option value="其他"></option>
</datalist>
</div>
<div class="wb-card">
<div class="wb-toolbar">
<input id="search-word" type="search" placeholder="搜索单词、英语或中文">
<select id="filter-category">
<option value="">全部分类</option>
</select>
<select id="sort-word">
<option value="created">最近收藏</option>
<option value="word">按单词 A-Z</option>
<option value="category">按分类</option>
</select>
</div>
<div class="wb-meta">
<span id="wb-total">共 0 个单词</span>
<span id="wb-category-count">0 个分类</span>
</div>
<div id="wb-list"></div>
</div>
<div id="edit-overlay">
<div id="edit-box">
<h3>编辑单词</h3>
<label>西语<input id="edit-word" type="text"></label>
<label>英语<input id="edit-english" type="text"></label>
<label>中文<input id="edit-chinese" type="text"></label>
<label>分类<input id="edit-category" type="text" list="category-options"></label>
<div class="modal-actions">
<button id="edit-cancel">取消</button>
<button id="edit-save" class="btn-confirm">保存</button>
</div>
</div>
</div>
<script src="api.js"></script>
<script src="speech.js"></script>
<script src="wordbook.js"></script>
</body>
</html>
+243
View File
@@ -0,0 +1,243 @@
var wbBackend = false;
var wbItems = [];
var wbEditId = null;
var LOCAL_WB_KEY = 'spanish_diary_wordbook';
function wbEsc(s) {
var div = document.createElement('div');
div.textContent = s == null ? '' : String(s);
return div.innerHTML;
}
function wbMsg(text) {
document.getElementById('wb-msg').textContent = text || '';
}
function loadLocalWords() {
try {
wbItems = JSON.parse(localStorage.getItem(LOCAL_WB_KEY) || '[]');
} catch (e) {
wbItems = [];
}
renderWordbook();
}
function saveLocalWords() {
localStorage.setItem(LOCAL_WB_KEY, JSON.stringify(wbItems));
}
function loadWords() {
if (wbBackend) {
apiGetVocabulary().then(function(items) {
wbItems = items || [];
renderWordbook();
}).catch(function(err) {
wbMsg(err.message || '加载单词本失败');
});
} else {
loadLocalWords();
}
}
function getFilteredWords() {
var keyword = document.getElementById('search-word').value.trim().toLowerCase();
var category = document.getElementById('filter-category').value;
var sort = document.getElementById('sort-word').value;
var filtered = wbItems.filter(function(item) {
var matchKeyword = !keyword ||
String(item.word || '').toLowerCase().indexOf(keyword) >= 0 ||
String(item.english || '').toLowerCase().indexOf(keyword) >= 0 ||
String(item.chinese || '').toLowerCase().indexOf(keyword) >= 0;
var matchCategory = !category || String(item.category || '') === category;
return matchKeyword && matchCategory;
});
filtered.sort(function(a, b) {
if (sort === 'word') return String(a.word || '').localeCompare(String(b.word || ''), 'es');
if (sort === 'category') {
var c = String(a.category || '').localeCompare(String(b.category || ''), 'zh-CN');
if (c !== 0) return c;
return String(a.word || '').localeCompare(String(b.word || ''), 'es');
}
return String(b.created_at || '').localeCompare(String(a.created_at || ''));
});
return filtered;
}
function renderWordbook() {
var categories = {};
wbItems.forEach(function(item) {
var cat = String(item.category || '其他').trim() || '其他';
categories[cat] = (categories[cat] || 0) + 1;
});
var filter = document.getElementById('filter-category');
var current = filter.value;
var categoryKeys = Object.keys(categories).sort(function(a, b) {
return a.localeCompare(b, 'zh-CN');
});
filter.innerHTML = '<option value="">全部分类</option>' + categoryKeys.map(function(cat) {
return '<option value="' + wbEsc(cat) + '"' + (cat === current ? ' selected' : '') + '>' +
wbEsc(cat) + ' (' + categories[cat] + ')</option>';
}).join('');
document.getElementById('wb-total').textContent = '共 ' + wbItems.length + ' 个单词';
document.getElementById('wb-category-count').textContent = categoryKeys.length + ' 个分类';
var filtered = getFilteredWords();
var container = document.getElementById('wb-list');
if (filtered.length === 0) {
container.innerHTML = '<div class="empty-state">还没有收藏单词,或没有匹配结果</div>';
return;
}
container.innerHTML = filtered.map(function(item) {
var cat = String(item.category || '其他').trim() || '其他';
return '<div class="word-card" data-id="' + item.id + '">' +
'<div class="word-main">' +
'<span class="word-text">' + wbEsc(item.word) + '</span>' +
'<span class="word-cat">' + wbEsc(cat) + '</span>' +
'</div>' +
'<div class="word-langs">' +
'<div class="lang-line"><span class="lang-label">ES</span><span class="lang-value">' + wbEsc(item.word) + '</span><button class="speak-word" data-lang="es">🔊</button></div>' +
'<div class="lang-line"><span class="lang-label">EN</span><span class="lang-value">' + wbEsc(item.english || '-') + '</span><button class="speak-word" data-lang="en">🔊</button></div>' +
'<div class="lang-line"><span class="lang-label">CN</span><span class="lang-value">' + wbEsc(item.chinese || '-') + '</span><button class="speak-word" data-lang="zh">🔊</button></div>' +
'</div>' +
'<div class="word-actions">' +
'<button class="edit-word">编辑</button>' +
'<button class="danger delete-word">删除</button>' +
'</div>' +
'</div>';
}).join('');
}
function addWord() {
var data = {
word: document.getElementById('add-word').value.trim(),
english: document.getElementById('add-english').value.trim(),
chinese: document.getElementById('add-chinese').value.trim(),
category: document.getElementById('add-category').value.trim()
};
if (!data.word) {
wbMsg('请输入西语单词');
return;
}
var finish = function() {
document.getElementById('add-word').value = '';
document.getElementById('add-english').value = '';
document.getElementById('add-chinese').value = '';
document.getElementById('add-category').value = '';
loadWords();
};
if (wbBackend) {
apiAddVocabulary(data).then(finish).catch(function(err) {
wbMsg(err.message || '添加失败');
});
} else {
data.id = 'local-' + Date.now();
data.created_at = new Date().toISOString();
wbItems.unshift(data);
saveLocalWords();
finish();
}
}
function openEdit(id) {
var item = wbItems.find(function(w) { return String(w.id) === String(id); });
if (!item) return;
wbEditId = id;
document.getElementById('edit-word').value = item.word || '';
document.getElementById('edit-english').value = item.english || '';
document.getElementById('edit-chinese').value = item.chinese || '';
document.getElementById('edit-category').value = item.category || '';
document.getElementById('edit-overlay').classList.add('show');
}
function closeEdit() {
wbEditId = null;
document.getElementById('edit-overlay').classList.remove('show');
}
function saveEdit() {
if (!wbEditId) return;
var data = {
word: document.getElementById('edit-word').value.trim(),
english: document.getElementById('edit-english').value.trim(),
chinese: document.getElementById('edit-chinese').value.trim(),
category: document.getElementById('edit-category').value.trim()
};
if (!data.word) {
wbMsg('请输入西语单词');
return;
}
var finish = function() {
closeEdit();
loadWords();
};
if (wbBackend) {
apiUpdateVocabulary(wbEditId, data).then(finish).catch(function(err) {
wbMsg(err.message || '保存失败');
});
} else {
var idx = wbItems.findIndex(function(w) { return String(w.id) === String(wbEditId); });
if (idx >= 0) {
Object.assign(wbItems[idx], data);
saveLocalWords();
}
finish();
}
}
function deleteWord(id) {
if (!confirm('确定从单词本中删除该单词吗?')) return;
var finish = function() {
loadWords();
};
if (wbBackend) {
apiDeleteVocabulary(id).then(finish).catch(function(err) {
wbMsg(err.message || '删除失败');
});
} else {
wbItems = wbItems.filter(function(w) { return String(w.id) !== String(id); });
saveLocalWords();
finish();
}
}
document.getElementById('btn-add-word').addEventListener('click', addWord);
document.getElementById('search-word').addEventListener('input', renderWordbook);
document.getElementById('filter-category').addEventListener('change', renderWordbook);
document.getElementById('sort-word').addEventListener('change', renderWordbook);
document.getElementById('edit-cancel').addEventListener('click', closeEdit);
document.getElementById('edit-save').addEventListener('click', saveEdit);
document.getElementById('wb-list').addEventListener('click', function(e) {
var card = e.target.closest('.word-card');
if (!card) return;
var id = card.dataset.id;
if (e.target.closest('.speak-word')) {
var item = wbItems.find(function(w) { return String(w.id) === String(id); });
if (!item) return;
var lang = e.target.closest('.speak-word').dataset.lang;
if (lang === 'es') speakText(item.word, 'es');
else if (lang === 'en') speakText(item.english, 'en');
else speakText(item.chinese, 'zh');
return;
}
if (e.target.closest('.edit-word')) {
openEdit(id);
return;
}
if (e.target.closest('.delete-word')) {
deleteWord(id);
}
});
apiGetMe().then(function() {
wbBackend = true;
loadWords();
}).catch(function(err) {
if (err && err.status === 401) {
location.href = 'login.html';
return;
}
wbBackend = false;
loadWords();
});