feat: 重构移动端布局并新增专用单词本页面
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
- 🌐 实时翻译:输入西语自动显示英语和中文对照
|
||||
- 🔊 发音朗读:支持西语、英语、中文三种语言(Web Speech API)
|
||||
- 📖 单词本:自动提取输入中的关键词并给出解释,支持点击发音
|
||||
- 📕 专用单词本页面:支持搜索、分类筛选、添加、编辑、删除和三语朗读
|
||||
- 📚 语法知识库:按教材整理的 40 个语音和语法知识点
|
||||
|
||||
## 使用
|
||||
@@ -84,4 +85,5 @@ server/data/spanish_diary.db
|
||||
- LibreTranslate 等自定义接口默认使用 `zh-hans` 表示简体中文,可通过扩展配置 `zh_code` 覆盖
|
||||
- 每用户日记统计
|
||||
- 每用户单词收藏
|
||||
- 移动端左上菜单、编辑区优先、左滑显示翻译
|
||||
- 手机、平板、桌面响应式适配
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user