diff --git a/README.md b/README.md
index c31d1ef..2e60fbd 100644
--- a/README.md
+++ b/README.md
@@ -14,19 +14,70 @@
直接打开 `index.html` 即可使用(推荐 Edge / Chrome)。
+## 阶段2:Python 后端
+
+安装依赖:
+
+```powershell
+cd spanish-diary
+python -m pip install -r server/requirements.txt
+```
+
+启动服务:
+
+```powershell
+python server/app.py
+```
+
+浏览器访问:
+
+```text
+http://localhost:5000
+```
+
+默认管理员账号:
+
+```text
+用户名:admin
+密码:admin123
+```
+
+> 生产环境请先修改管理员密码,并通过环境变量设置 `SECRET_KEY`。
+
## 文件结构
```
index.html — 主页面
style.css — 主页面样式
app.js — 应用逻辑
+api.js — 后端 API 客户端
db.js — IndexedDB 数据存储
translate.js — 翻译模块
speech.js — 发音模块
+login.html — 登录/注册
+admin.html — 用户管理
knowledge.html — 知识库首页
knowledge/ — 6 个分类知识页面
+server/ — Python Flask 后端
```
## 数据存储
-阶段 1 使用浏览器 IndexedDB 本地存储,无需后端。后续计划迁移到 Python + SQLite。
+阶段 1 使用浏览器 IndexedDB 本地存储,无需后端。
+
+阶段 2 使用 SQLite 数据库:
+
+```text
+server/data/spanish_diary.db
+```
+
+## 阶段2 功能
+
+- 用户注册、登录、登出
+- 管理员添加、禁用、删除用户
+- 用户日记数据隔离
+- 共享知识库
+- 每用户翻译配置(本地 / MyMemory / 自定义接口)
+- 每用户日记统计
+- 每用户单词收藏
+- 手机、平板、桌面响应式适配
diff --git a/admin.html b/admin.html
new file mode 100644
index 0000000..c410cf3
--- /dev/null
+++ b/admin.html
@@ -0,0 +1,149 @@
+
+
+
+
+
+ 用户管理 — Mi Diario
+
+
+
+
+ ← 返回日记
+ 用户管理
+
+
+
+
+
+
+
+
+
+
diff --git a/api.js b/api.js
new file mode 100644
index 0000000..163c5e3
--- /dev/null
+++ b/api.js
@@ -0,0 +1,101 @@
+async function apiFetch(path, options) {
+ const opts = options || {};
+ const headers = Object.assign({}, opts.headers || {});
+ if (opts.body && !headers['Content-Type']) {
+ headers['Content-Type'] = 'application/json';
+ }
+ const response = await fetch(path, {
+ method: opts.method || 'GET',
+ headers: headers,
+ body: opts.body ? JSON.stringify(opts.body) : undefined,
+ credentials: 'same-origin'
+ });
+ let data = null;
+ try {
+ data = await response.json();
+ } catch (e) {
+ data = {};
+ }
+ if (!response.ok) {
+ const error = new Error((data && data.error) || '请求失败: ' + response.status);
+ error.status = response.status;
+ throw error;
+ }
+ return data;
+}
+
+function apiGetMe() {
+ return apiFetch('/api/me');
+}
+
+function apiLogin(username, password) {
+ return apiFetch('/api/login', { method: 'POST', body: { username: username, password: password } });
+}
+
+function apiRegister(username, password, displayName) {
+ return apiFetch('/api/register', { method: 'POST', body: { username: username, password: password, displayName: displayName } });
+}
+
+function apiLogout() {
+ return apiFetch('/api/logout', { method: 'POST' });
+}
+
+function apiGetEntries() {
+ return apiFetch('/api/entries');
+}
+
+function apiAddEntry(data) {
+ return apiFetch('/api/entries', { method: 'POST', body: data });
+}
+
+function apiUpdateEntry(id, data) {
+ return apiFetch('/api/entries/' + id, { method: 'PUT', body: data });
+}
+
+function apiDeleteEntry(id) {
+ return apiFetch('/api/entries/' + id, { method: 'DELETE' });
+}
+
+function apiGetStats() {
+ return apiFetch('/api/entries/stats');
+}
+
+function apiGetVocabulary() {
+ return apiFetch('/api/vocabulary');
+}
+
+function apiAddVocabulary(data) {
+ return apiFetch('/api/vocabulary', { method: 'POST', body: data });
+}
+
+function apiDeleteVocabulary(id) {
+ return apiFetch('/api/vocabulary/' + id, { method: 'DELETE' });
+}
+
+function apiGetTranslationSettings() {
+ return apiFetch('/api/settings/translation');
+}
+
+function apiSaveTranslationSettings(data) {
+ return apiFetch('/api/settings/translation', { method: 'PUT', body: data });
+}
+
+function apiTranslate(data) {
+ return apiFetch('/api/translate', { method: 'POST', body: data });
+}
+
+function apiAdminUsers() {
+ return apiFetch('/api/admin/users');
+}
+
+function apiAdminCreateUser(data) {
+ return apiFetch('/api/admin/users', { method: 'POST', body: data });
+}
+
+function apiAdminSetStatus(id, isActive) {
+ return apiFetch('/api/admin/users/' + id + '/status', { method: 'PUT', body: { isActive: isActive } });
+}
+
+function apiAdminDeleteUser(id) {
+ return apiFetch('/api/admin/users/' + id, { method: 'DELETE' });
+}
diff --git a/app.js b/app.js
index 5facaeb..fef4f86 100644
--- a/app.js
+++ b/app.js
@@ -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
+ ? ''
+ : '';
return '' +
'' + escHtml(v.word) + '' +
'' +
@@ -107,6 +139,7 @@ function updateVocabulary(text) {
'' +
'' + escHtml(v.chinese) + '' +
'' +
+ favBtn +
'
';
}).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 =
+ '' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '最近 7 天:' + escHtml(dailyHtml) + '
' +
+ '累计西语字符:' + stats.totalChars + '
';
+ }).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);
diff --git a/docs/2026-07-31-spanish-diary-design.md b/docs/2026-07-31-spanish-diary-design.md
new file mode 100644
index 0000000..5ec894a
--- /dev/null
+++ b/docs/2026-07-31-spanish-diary-design.md
@@ -0,0 +1,347 @@
+# 西班牙语日记本 — 设计文档
+
+## 概述
+
+一个日常使用的西班牙语学习工具。用户每天用西语写日记,系统自动显示英语和中文翻译,支持发音朗读。数据持久化存储,分两阶段交付。
+
+## 项目阶段
+
+| 阶段 | 存储 | 翻译 | 部署 |
+|------|------|------|------|
+| 阶段1 (纯前端) | IndexedDB | Mock 数据 | 浏览器直接打开 |
+| 阶段2 (Python 后端) | SQLite | 翻译 API | Flask/FastAPI + 静态文件 |
+
+## 布局方案
+
+选择 **方案B — 左右对照布局**
+
+### 三栏结构
+
+```
++-------------------+---------------------+----------------------+
+| 左侧面板 | 主编辑区 | 右侧面板 |
+| 日记列表 | 西语输入 | 翻译区 |
+| | | |
+| 📅 2026-07-31 | 📝 Mi Diario | English |
+| Hoy fui al... | (text area) | Today I went to... |
+| 📅 2026-07-30 | | |
+| Ayer aprendí... | [🔊 保存] | 中文 |
+| 📅 2026-07-29 | | 今天我去了... |
+| Estoy cansado... | | |
+| | | [🔊 发音] |
+| [+ 新建] | | |
++-------------------+---------------------+----------------------+
+```
+
+### 左侧面板 — 日记列表
+
+- 按日期倒序显示所有记录
+- 每条显示:日期 + 西语内容前 20 字预览
+- 当前选中的记录高亮
+- 顶部 "+" 按钮新建当日记录
+- 每条记录悬停时显示编辑/删除图标
+- 删除需要二次确认
+
+### 主编辑区 — 西语输入
+
+- 标题自动设为当天日期(格式:2026-07-31),可手动修改
+- 可编辑文本区用于输入西语日记
+- 底部保存按钮
+- 保存后自动更新左侧列表和右侧翻译
+
+### 右侧面板 — 翻译区
+
+- 只读展示,不可编辑
+- 上半部分:英文翻译
+- 下半部分:中文翻译
+- 每条翻译下方有 🔊 发音按钮(点击朗读对应语言的翻译文本)
+
+## 功能需求
+
+### 阶段1 (纯前端)
+
+1. **CRUD 操作**
+ - 新建日记:自动填入当天日期,西语内容为空
+ - 编辑:点击已有记录,在主编辑区修改后保存
+ - 删除:悬停时出现删除按钮,点击后弹窗确认
+
+2. **翻译功能**
+ - 阶段1 使用内置 mock 翻译数据(有限的预设短语库)
+ - 保存后立即在右侧显示英/中文对照翻译
+ - 纯前端无法调用翻译 API,预设覆盖日常日记常用句式
+ 如 "Hoy fui a..."、"Estoy..."、"Ayer..."、"Me gusta..." 等
+ - 不在预设库中的内容显示原文 + 占位提示
+
+3. **发音功能**
+ - 使用浏览器 Web Speech API (SpeechSynthesis)
+ - 支持西班牙语、英语、中文三种语言的文本朗读
+
+4. **数据存储**
+ - 使用 IndexedDB
+ - 数据模型:
+ ```json
+ {
+ "id": "uuid",
+ "date": "2026-07-31",
+ "spanish": "Hoy fui al parque con mi amigo.",
+ "english": "Today I went to the park with my friend.",
+ "chinese": "今天我和朋友去了公园。",
+ "createdAt": "2026-07-31T10:00:00Z",
+ "updatedAt": "2026-07-31T10:00:00Z"
+ }
+ ```
+
+### 阶段2 (Python 后端)
+
+#### 2.1 后端与存储
+
+- 使用 Python + Flask 提供 REST API,并直接托管前端静态文件
+- 使用 SQLite 数据库,表结构见下方“数据库设计”
+- 密码使用 Werkzeug 哈希存储,禁止明文
+- 使用服务端 Session 管理登录状态,Cookie 存储 session id
+
+#### 2.2 用户管理
+
+- 注册:用户名、密码、显示名
+- 登录 / 登出
+- 管理员用户可:
+ - 查看全部用户
+ - 添加用户
+ - 禁用 / 启用用户
+ - 删除用户(同时删除该用户日记、收藏单词等数据)
+- 普通用户不能访问管理员接口
+- 被禁用用户无法登录,已登录会话立即失效
+
+#### 2.3 数据隔离
+
+- 日记、统计、收藏单词、翻译配置全部按 `user_id` 隔离
+- 所有查询必须带当前登录用户的 `user_id`
+- 服务端校验资源归属,禁止通过修改 URL/ID 访问他人数据
+
+#### 2.4 知识库共享
+
+- 知识库为公共静态页面,所有用户共享
+- 不要求登录即可浏览知识库
+
+#### 2.5 翻译配置
+
+- 每个用户可以配置自己的翻译方式:
+ - 本地词典
+ - MyMemory 免费接口
+ - 自定义 HTTP 翻译接口(可配置 URL、请求头、字段映射)
+- 配置项保存在用户自己的 `settings` 表中
+- 后端执行在线翻译,避免浏览器跨域限制,也便于统一记录配置
+
+#### 2.6 日记统计
+
+- 每个用户可查看自己的统计:
+ - 总日记数
+ - 本月日记数
+ - 本周日记数
+ - 连续写作天数(streak)
+ - 最近 7 天每日条数
+ - 总西语字符数 / 平均每天字数
+
+#### 2.7 单词本
+
+- 每个用户可收藏单词
+- 单词来自 B1 词库或日记中出现的词
+- 收藏内容包括:西语单词、英语释义、中文释义、主题分类
+- 支持添加、删除、列表查询
+
+#### 2.8 前端适配
+
+- 桌面:三栏布局
+- 平板(768px - 1024px):两栏或紧凑三栏
+- 手机(< 768px):单栏,列表、编辑、翻译通过页面区域切换
+- 按钮触控区域不小于 40px,滚动区域适配触摸
+- 保留 Web Speech API 发音
+
+#### 2.9 安全
+
+- 密码哈希存储
+- Session Cookie 使用 `HttpOnly`、`SameSite=Lax`
+- API 输入校验,防止 SQL 注入和 XSS
+- 删除用户为级联删除
+- 生产部署建议使用反向代理 + HTTPS
+
+## 技术方案
+
+### 阶段1 技术栈
+
+- 纯 HTML + CSS + JavaScript
+- IndexedDB 使用原生 API
+- Web Speech API 用于发音
+- 无构建工具,无依赖
+
+### 阶段2 技术栈
+
+- Python 3 + Flask
+- SQLite(Python 内置 sqlite3)
+- Werkzeug(Flask 自带密码哈希)
+- Vanilla JS 前端,保持无构建工具
+
+### 项目文件结构 (阶段2)
+
+```
+spanish-diary/
+ server/
+ app.py — Flask 应用入口与路由
+ db.py — SQLite 初始化与连接
+ auth.py — 登录/会话/管理员权限
+ entries_api.py — 日记 CRUD 与统计
+ vocab_api.py — 单词本接口
+ settings_api.py — 翻译配置接口
+ translate_service.py — 本地/MyMemory/自定义翻译
+ index.html — 主页面
+ style.css — 样式
+ app.js — 应用逻辑
+ api.js — 后端 API 客户端
+ db.js — IndexedDB 操作封装(阶段1兼容)
+ translate.js — 翻译模块
+ speech.js — 发音模块
+ knowledge.html — 知识库首页
+ knowledge/ — 知识库分类页
+ b1-vocab.js — B1 词库
+```
+
+### 数据库设计
+
+```sql
+CREATE TABLE users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ username TEXT UNIQUE NOT NULL,
+ password_hash TEXT NOT NULL,
+ display_name TEXT NOT NULL,
+ is_admin INTEGER NOT NULL DEFAULT 0,
+ is_active INTEGER NOT NULL DEFAULT 1,
+ created_at TEXT NOT NULL
+);
+
+CREATE TABLE entries (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ date TEXT NOT NULL,
+ spanish TEXT NOT NULL,
+ english TEXT NOT NULL DEFAULT '',
+ chinese TEXT NOT NULL DEFAULT '',
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+
+CREATE INDEX idx_entries_user_date ON entries(user_id, date);
+
+CREATE TABLE vocabulary (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ word TEXT NOT NULL,
+ english TEXT NOT NULL DEFAULT '',
+ chinese TEXT NOT NULL DEFAULT '',
+ category TEXT NOT NULL DEFAULT '',
+ created_at TEXT NOT NULL,
+ UNIQUE(user_id, word)
+);
+
+CREATE TABLE settings (
+ user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
+ translator TEXT NOT NULL DEFAULT 'local',
+ api_url TEXT NOT NULL DEFAULT '',
+ api_key TEXT NOT NULL DEFAULT '',
+ extra_config TEXT NOT NULL DEFAULT '{}',
+ updated_at TEXT NOT NULL
+);
+```
+
+### API 设计
+
+#### 用户与认证
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| POST | /api/register | 注册 |
+| POST | /api/login | 登录 |
+| POST | /api/logout | 登出 |
+| GET | /api/me | 当前用户信息 |
+| GET | /api/admin/users | 管理员查看用户 |
+| POST | /api/admin/users | 管理员添加用户 |
+| PUT | /api/admin/users//status | 启用/禁用 |
+| DELETE | /api/admin/users/ | 删除用户 |
+
+#### 日记与统计
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET | /api/entries | 当前用户日记列表 |
+| POST | /api/entries | 新建日记 |
+| PUT | /api/entries/ | 编辑日记 |
+| DELETE | /api/entries/ | 删除日记 |
+| GET | /api/entries/stats | 当前用户统计 |
+
+#### 单词本
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET | /api/vocabulary | 收藏列表 |
+| POST | /api/vocabulary | 收藏单词 |
+| DELETE | /api/vocabulary/ | 删除收藏 |
+
+#### 翻译配置
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET | /api/settings/translation | 获取翻译配置 |
+| PUT | /api/settings/translation | 更新翻译配置 |
+| POST | /api/translate | 翻译接口 |
+
+### 前端兼容性
+
+- 桌面:三栏
+- 平板 768-1024px:两栏,编辑区优先
+- 手机 <768px:单栏,通过标签或底部导航切换“日记 / 编辑 / 翻译 / 我的”
+- 使用 CSS Grid + Media Query
+- 输入框和按钮适配触摸操作
+- 所有页面在 Chrome、Edge、Safari 手机端可用
+
+## 交互细节
+
+- 点击左侧列表中的记录 → 主编辑区加载该记录内容,右侧显示翻译
+- 阶段1:修改主编辑区内容后点击保存 → 更新 IndexedDB,刷新左侧列表和右侧翻译
+- 阶段2:通过后端 API 保存,同一账号在不同设备可同步
+- 点击 🔊 → 使用 Web Speech API 朗读文本(自动选择对应语言语音)
+- 选择日期与已有记录相同 → 打开该记录而非新建
+
+## 成功标准
+
+### 阶段1
+
+- 打开 HTML 文件即可使用,无需任何安装
+- 新建、编辑、删除操作流畅无刷新
+- 翻译在保存后即时显示
+- 发音功能在主流浏览器(Chrome/Edge)正常工作
+- 页面关闭后重新打开,数据完整保留
+
+### 阶段2
+
+- 用户可注册、登录、登出
+- 管理员可添加、禁用、删除用户
+- 不同用户日记数据完全隔离
+- 知识库对所有用户共享
+- 每个用户可配置翻译方式和在线翻译
+- 每个用户可查看日记统计
+- 每个用户可收藏和管理自己的单词
+- 前端在手机、平板、桌面均可用
+- 后端 API 启动后前端通过 API 读写数据
+- 未登录时不能访问个人数据接口
+- 被禁用用户无法登录且已有会话失效
+- 密码不以明文保存
+
+## 后续阶段
+
+- 阶段3 可增加:
+ - 忘记密码与邮箱验证
+ - 用户头像与个人资料
+ - 分享日记
+ - 导出/导入数据
+ - Docker 部署
+ - HTTPS 部署
+
diff --git a/index.html b/index.html
index 30dc04e..ead9e9f 100644
--- a/index.html
+++ b/index.html
@@ -7,6 +7,13 @@
+
+
@@ -111,6 +132,30 @@
+
+
+
+
翻译设置
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/login.html b/login.html
new file mode 100644
index 0000000..ad9101c
--- /dev/null
+++ b/login.html
@@ -0,0 +1,92 @@
+
+
+
+
+
+ 登录 — Mi Diario 西班牙语日记
+
+
+
+
+
+
登录
+
Mi Diario 西班牙语日记
+
+
+
+
返回首页
+
+
+
+
+
+
diff --git a/server/__init__.py b/server/__init__.py
new file mode 100644
index 0000000..04d3c86
--- /dev/null
+++ b/server/__init__.py
@@ -0,0 +1 @@
+"""Spanish Diary backend package."""
diff --git a/server/__pycache__/__init__.cpython-311.pyc b/server/__pycache__/__init__.cpython-311.pyc
new file mode 100644
index 0000000..3bed8d3
Binary files /dev/null and b/server/__pycache__/__init__.cpython-311.pyc differ
diff --git a/server/__pycache__/app.cpython-311.pyc b/server/__pycache__/app.cpython-311.pyc
new file mode 100644
index 0000000..e231c8d
Binary files /dev/null and b/server/__pycache__/app.cpython-311.pyc differ
diff --git a/server/__pycache__/auth.cpython-311.pyc b/server/__pycache__/auth.cpython-311.pyc
new file mode 100644
index 0000000..6216056
Binary files /dev/null and b/server/__pycache__/auth.cpython-311.pyc differ
diff --git a/server/__pycache__/db.cpython-311.pyc b/server/__pycache__/db.cpython-311.pyc
new file mode 100644
index 0000000..d4e2ab2
Binary files /dev/null and b/server/__pycache__/db.cpython-311.pyc differ
diff --git a/server/__pycache__/entries_api.cpython-311.pyc b/server/__pycache__/entries_api.cpython-311.pyc
new file mode 100644
index 0000000..3f90717
Binary files /dev/null and b/server/__pycache__/entries_api.cpython-311.pyc differ
diff --git a/server/__pycache__/settings_api.cpython-311.pyc b/server/__pycache__/settings_api.cpython-311.pyc
new file mode 100644
index 0000000..c09b9b5
Binary files /dev/null and b/server/__pycache__/settings_api.cpython-311.pyc differ
diff --git a/server/__pycache__/translate_service.cpython-311.pyc b/server/__pycache__/translate_service.cpython-311.pyc
new file mode 100644
index 0000000..f696fb8
Binary files /dev/null and b/server/__pycache__/translate_service.cpython-311.pyc differ
diff --git a/server/__pycache__/vocab_api.cpython-311.pyc b/server/__pycache__/vocab_api.cpython-311.pyc
new file mode 100644
index 0000000..0100a5a
Binary files /dev/null and b/server/__pycache__/vocab_api.cpython-311.pyc differ
diff --git a/server/app.py b/server/app.py
new file mode 100644
index 0000000..61d74e9
--- /dev/null
+++ b/server/app.py
@@ -0,0 +1,201 @@
+import os
+
+from flask import Flask, jsonify, request, send_from_directory, session
+
+from .auth import admin_required, authenticate, create_user, login_required
+from .db import BASE_DIR, get_db, init_db
+from .entries_api import entries_bp
+from .settings_api import settings_bp
+from .vocab_api import vocab_bp
+
+
+def create_app():
+ app = Flask(__name__, static_folder=None)
+ app.secret_key = os.environ.get("SECRET_KEY", "spanish-diary-dev-secret-change-me")
+ app.config.update(
+ SESSION_COOKIE_HTTPONLY=True,
+ SESSION_COOKIE_SAMESITE="Lax",
+ SESSION_COOKIE_SECURE=os.environ.get("COOKIE_SECURE", "0") == "1",
+ MAX_CONTENT_LENGTH=2 * 1024 * 1024,
+ )
+
+ init_db()
+ app.register_blueprint(entries_bp)
+ app.register_blueprint(vocab_bp)
+ app.register_blueprint(settings_bp)
+
+ @app.post("/api/register")
+ def register():
+ data = request.get_json(silent=True) or {}
+ username = (data.get("username") or "").strip()
+ password = data.get("password") or ""
+ display_name = (data.get("displayName") or username or "用户").strip()
+ if len(username) < 3 or len(username) > 50:
+ return jsonify({"error": "用户名长度需为 3-50 个字符"}), 400
+ if len(password) < 6:
+ return jsonify({"error": "密码至少 6 位"}), 400
+ conn = get_db()
+ try:
+ exists = conn.execute(
+ "SELECT id FROM users WHERE username = ?", (username,)
+ ).fetchone()
+ finally:
+ conn.close()
+ if exists:
+ return jsonify({"error": "用户名已存在"}), 409
+ user = create_user(username, password, display_name)
+ session["user_id"] = user["id"]
+ session.permanent = True
+ return jsonify(dict(user)), 201
+
+ @app.post("/api/login")
+ def login():
+ data = request.get_json(silent=True) or {}
+ username = (data.get("username") or "").strip()
+ password = data.get("password") or ""
+ user = authenticate(username, password)
+ if user is None:
+ return jsonify({"error": "用户名或密码错误"}), 401
+ if user.get("disabled"):
+ return jsonify({"error": "账号已被禁用"}), 403
+ session["user_id"] = user["id"]
+ session.permanent = True
+ return jsonify(
+ {
+ "id": user["id"],
+ "username": user["username"],
+ "displayName": user["display_name"],
+ "isAdmin": bool(user["is_admin"]),
+ "isActive": bool(user["is_active"]),
+ }
+ )
+
+ @app.post("/api/logout")
+ def logout():
+ session.clear()
+ return jsonify({"ok": True})
+
+ @app.get("/api/me")
+ @login_required
+ def me(user):
+ return jsonify(
+ {
+ "id": user["id"],
+ "username": user["username"],
+ "displayName": user["display_name"],
+ "isAdmin": bool(user["is_admin"]),
+ "isActive": bool(user["is_active"]),
+ }
+ )
+
+ @app.get("/api/admin/users")
+ @admin_required
+ def admin_list_users(user):
+ conn = get_db()
+ try:
+ rows = conn.execute(
+ """
+ SELECT id, username, display_name, is_admin, is_active, created_at
+ FROM users
+ ORDER BY id
+ """
+ ).fetchall()
+ return jsonify([dict(r) for r in rows])
+ finally:
+ conn.close()
+
+ @app.post("/api/admin/users")
+ @admin_required
+ def admin_create_user(user):
+ data = request.get_json(silent=True) or {}
+ username = (data.get("username") or "").strip()
+ password = data.get("password") or ""
+ display_name = (data.get("displayName") or username or "用户").strip()
+ if len(username) < 3 or len(username) > 50:
+ return jsonify({"error": "用户名长度需为 3-50 个字符"}), 400
+ if len(password) < 6:
+ return jsonify({"error": "密码至少 6 位"}), 400
+ conn = get_db()
+ try:
+ exists = conn.execute(
+ "SELECT id FROM users WHERE username = ?", (username,)
+ ).fetchone()
+ finally:
+ conn.close()
+ if exists:
+ return jsonify({"error": "用户名已存在"}), 409
+ created = create_user(
+ username,
+ password,
+ display_name,
+ is_admin=bool(data.get("isAdmin")),
+ is_active=bool(data.get("isActive", True)),
+ )
+ return jsonify(dict(created)), 201
+
+ @app.put("/api/admin/users//status")
+ @admin_required
+ def admin_update_status(user, user_id):
+ data = request.get_json(silent=True) or {}
+ if "isActive" not in data:
+ return jsonify({"error": "缺少 isActive 参数"}), 400
+ conn = get_db()
+ try:
+ target = conn.execute(
+ "SELECT id FROM users WHERE id = ?", (user_id,)
+ ).fetchone()
+ if target is None:
+ return jsonify({"error": "用户不存在"}), 404
+ conn.execute(
+ "UPDATE users SET is_active = ? WHERE id = ?",
+ (1 if data["isActive"] else 0, user_id),
+ )
+ conn.commit()
+ row = conn.execute(
+ """
+ SELECT id, username, display_name, is_admin, is_active, created_at
+ FROM users WHERE id = ?
+ """,
+ (user_id,),
+ ).fetchone()
+ return jsonify(dict(row))
+ finally:
+ conn.close()
+
+ @app.delete("/api/admin/users/")
+ @admin_required
+ def admin_delete_user(user, user_id):
+ if user_id == user["id"]:
+ return jsonify({"error": "不能删除当前登录账号"}), 400
+ conn = get_db()
+ try:
+ target = conn.execute(
+ "SELECT id FROM users WHERE id = ?", (user_id,)
+ ).fetchone()
+ if target is None:
+ return jsonify({"error": "用户不存在"}), 404
+ conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
+ conn.commit()
+ return jsonify({"ok": True})
+ finally:
+ conn.close()
+
+ @app.get("/")
+ def index():
+ return send_from_directory(BASE_DIR, "index.html")
+
+ @app.get("/")
+ def static_files(file_path):
+ full_path = os.path.join(BASE_DIR, file_path)
+ if os.path.isfile(full_path):
+ return send_from_directory(BASE_DIR, file_path)
+ return jsonify({"error": "页面不存在"}), 404
+
+ return app
+
+
+app = create_app()
+
+
+if __name__ == "__main__":
+ app.run(host="0.0.0.0", port=5000, debug=False)
diff --git a/server/auth.py b/server/auth.py
new file mode 100644
index 0000000..67eea67
--- /dev/null
+++ b/server/auth.py
@@ -0,0 +1,84 @@
+from functools import wraps
+
+from flask import jsonify, session
+from werkzeug.security import check_password_hash, generate_password_hash
+
+from .db import get_db, now_iso
+
+
+def get_current_user():
+ user_id = session.get("user_id")
+ if not user_id:
+ return None
+ conn = get_db()
+ try:
+ row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
+ if row is None or not row["is_active"]:
+ session.clear()
+ return None
+ return dict(row)
+ finally:
+ conn.close()
+
+
+def login_required(view):
+ @wraps(view)
+ def wrapped(*args, **kwargs):
+ user = get_current_user()
+ if user is None:
+ return jsonify({"error": "请先登录"}), 401
+ return view(user=user, *args, **kwargs)
+
+ return wrapped
+
+
+def admin_required(view):
+ @wraps(view)
+ def wrapped(*args, **kwargs):
+ user = get_current_user()
+ if user is None:
+ return jsonify({"error": "请先登录"}), 401
+ if not user["is_admin"]:
+ return jsonify({"error": "无管理员权限"}), 403
+ return view(user=user, *args, **kwargs)
+
+ return wrapped
+
+
+def create_user(username, password, display_name, is_admin=False, is_active=True):
+ conn = get_db()
+ try:
+ conn.execute(
+ """
+ INSERT INTO users (username, password_hash, display_name, is_admin, is_active, created_at)
+ VALUES (?, ?, ?, ?, ?, ?)
+ """,
+ (
+ username,
+ generate_password_hash(password),
+ display_name,
+ 1 if is_admin else 0,
+ 1 if is_active else 0,
+ now_iso(),
+ ),
+ )
+ conn.commit()
+ return conn.execute(
+ "SELECT id, username, display_name, is_admin, is_active, created_at FROM users WHERE username = ?",
+ (username,),
+ ).fetchone()
+ finally:
+ conn.close()
+
+
+def authenticate(username, password):
+ conn = get_db()
+ try:
+ row = conn.execute("SELECT * FROM users WHERE username = ?", (username,)).fetchone()
+ if row is None or not check_password_hash(row["password_hash"], password):
+ return None
+ if not row["is_active"]:
+ return {"disabled": True}
+ return dict(row)
+ finally:
+ conn.close()
diff --git a/server/data/spanish_diary.db b/server/data/spanish_diary.db
new file mode 100644
index 0000000..2aa89e8
Binary files /dev/null and b/server/data/spanish_diary.db differ
diff --git a/server/db.py b/server/db.py
new file mode 100644
index 0000000..2a50230
--- /dev/null
+++ b/server/db.py
@@ -0,0 +1,93 @@
+import os
+import sqlite3
+from datetime import datetime, timezone
+
+from werkzeug.security import generate_password_hash
+
+BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+DATA_DIR = os.path.join(BASE_DIR, "server", "data")
+DB_PATH = os.path.join(DATA_DIR, "spanish_diary.db")
+
+SCHEMA = """
+CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ username TEXT UNIQUE NOT NULL,
+ password_hash TEXT NOT NULL,
+ display_name TEXT NOT NULL,
+ is_admin INTEGER NOT NULL DEFAULT 0,
+ is_active INTEGER NOT NULL DEFAULT 1,
+ created_at TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS entries (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ date TEXT NOT NULL,
+ spanish TEXT NOT NULL,
+ english TEXT NOT NULL DEFAULT '',
+ chinese TEXT NOT NULL DEFAULT '',
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_entries_user_date ON entries(user_id, date);
+
+CREATE TABLE IF NOT EXISTS vocabulary (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ word TEXT NOT NULL,
+ english TEXT NOT NULL DEFAULT '',
+ chinese TEXT NOT NULL DEFAULT '',
+ category TEXT NOT NULL DEFAULT '',
+ created_at TEXT NOT NULL,
+ UNIQUE(user_id, word)
+);
+
+CREATE TABLE IF NOT EXISTS settings (
+ user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
+ translator TEXT NOT NULL DEFAULT 'local',
+ api_url TEXT NOT NULL DEFAULT '',
+ api_key TEXT NOT NULL DEFAULT '',
+ extra_config TEXT NOT NULL DEFAULT '{}',
+ updated_at TEXT NOT NULL
+);
+"""
+
+
+def now_iso():
+ return datetime.now(timezone.utc).isoformat()
+
+
+def get_db():
+ conn = sqlite3.connect(DB_PATH)
+ conn.row_factory = sqlite3.Row
+ conn.execute("PRAGMA foreign_keys = ON")
+ return conn
+
+
+def init_db():
+ os.makedirs(DATA_DIR, exist_ok=True)
+ conn = get_db()
+ try:
+ conn.executescript(SCHEMA)
+ conn.commit()
+
+ admin = conn.execute(
+ "SELECT id FROM users WHERE username = ?", ("admin",)
+ ).fetchone()
+ if admin is None:
+ conn.execute(
+ """
+ INSERT INTO users (username, password_hash, display_name, is_admin, is_active, created_at)
+ VALUES (?, ?, ?, 1, 1, ?)
+ """,
+ (
+ "admin",
+ generate_password_hash("admin123"),
+ "管理员",
+ now_iso(),
+ ),
+ )
+ conn.commit()
+ finally:
+ conn.close()
diff --git a/server/entries_api.py b/server/entries_api.py
new file mode 100644
index 0000000..4abe60d
--- /dev/null
+++ b/server/entries_api.py
@@ -0,0 +1,173 @@
+from datetime import date, timedelta
+
+from flask import Blueprint, jsonify, request
+
+from .auth import login_required
+from .db import get_db, now_iso
+
+entries_bp = Blueprint("entries", __name__)
+
+
+def row_to_dict(row):
+ return dict(row)
+
+
+@entries_bp.get("/api/entries")
+@login_required
+def list_entries(user):
+ conn = get_db()
+ try:
+ rows = conn.execute(
+ """
+ SELECT id, date, spanish, english, chinese, created_at, updated_at
+ FROM entries
+ WHERE user_id = ?
+ ORDER BY date DESC, id DESC
+ """,
+ (user["id"],),
+ ).fetchall()
+ return jsonify([row_to_dict(r) for r in rows])
+ finally:
+ conn.close()
+
+
+@entries_bp.post("/api/entries")
+@login_required
+def create_entry(user):
+ data = request.get_json(silent=True) or {}
+ spanish = (data.get("spanish") or "").strip()
+ if not spanish:
+ return jsonify({"error": "日记内容不能为空"}), 400
+ entry_date = (data.get("date") or date.today().isoformat()).strip()
+ conn = get_db()
+ try:
+ cur = conn.execute(
+ """
+ INSERT INTO entries (user_id, date, spanish, english, chinese, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ user["id"],
+ entry_date,
+ spanish,
+ (data.get("english") or "").strip(),
+ (data.get("chinese") or "").strip(),
+ now_iso(),
+ now_iso(),
+ ),
+ )
+ conn.commit()
+ row = conn.execute(
+ "SELECT id, date, spanish, english, chinese, created_at, updated_at FROM entries WHERE id = ?",
+ (cur.lastrowid,),
+ ).fetchone()
+ return jsonify(row_to_dict(row)), 201
+ finally:
+ conn.close()
+
+
+@entries_bp.put("/api/entries/")
+@login_required
+def update_entry(user, entry_id):
+ data = request.get_json(silent=True) or {}
+ spanish = (data.get("spanish") or "").strip()
+ if not spanish:
+ return jsonify({"error": "日记内容不能为空"}), 400
+ conn = get_db()
+ try:
+ row = conn.execute(
+ "SELECT id FROM entries WHERE id = ? AND user_id = ?",
+ (entry_id, user["id"]),
+ ).fetchone()
+ if row is None:
+ return jsonify({"error": "日记不存在或无权访问"}), 404
+ conn.execute(
+ """
+ UPDATE entries
+ SET date = ?, spanish = ?, english = ?, chinese = ?, updated_at = ?
+ WHERE id = ?
+ """,
+ (
+ (data.get("date") or row["date"]).strip(),
+ spanish,
+ (data.get("english") or "").strip(),
+ (data.get("chinese") or "").strip(),
+ now_iso(),
+ entry_id,
+ ),
+ )
+ conn.commit()
+ updated = conn.execute(
+ "SELECT id, date, spanish, english, chinese, created_at, updated_at FROM entries WHERE id = ?",
+ (entry_id,),
+ ).fetchone()
+ return jsonify(row_to_dict(updated))
+ finally:
+ conn.close()
+
+
+@entries_bp.delete("/api/entries/")
+@login_required
+def delete_entry(user, entry_id):
+ conn = get_db()
+ try:
+ row = conn.execute(
+ "SELECT id FROM entries WHERE id = ? AND user_id = ?",
+ (entry_id, user["id"]),
+ ).fetchone()
+ if row is None:
+ return jsonify({"error": "日记不存在或无权访问"}), 404
+ conn.execute("DELETE FROM entries WHERE id = ?", (entry_id,))
+ conn.commit()
+ return jsonify({"ok": True})
+ finally:
+ conn.close()
+
+
+@entries_bp.get("/api/entries/stats")
+@login_required
+def entry_stats(user):
+ conn = get_db()
+ try:
+ rows = conn.execute(
+ """
+ SELECT date, spanish FROM entries
+ WHERE user_id = ?
+ """,
+ (user["id"],),
+ ).fetchall()
+ total = len(rows)
+ total_chars = sum(len(r["spanish"]) for r in rows)
+ dates = [r["date"] for r in rows]
+
+ today = date.today()
+ month_start = today.replace(day=1)
+ week_start = today - timedelta(days=today.weekday())
+ month_count = sum(1 for d in dates if d >= month_start.isoformat())
+ week_count = sum(1 for d in dates if d >= week_start.isoformat())
+
+ streak = 0
+ cursor = today
+ if cursor.isoformat() not in dates:
+ cursor -= timedelta(days=1)
+ while cursor.isoformat() in dates:
+ streak += 1
+ cursor -= timedelta(days=1)
+
+ daily = []
+ for offset in range(6, -1, -1):
+ day = today - timedelta(days=offset)
+ daily.append({"date": day.isoformat(), "count": sum(1 for d in dates if d == day.isoformat())})
+
+ return jsonify(
+ {
+ "total": total,
+ "month": month_count,
+ "week": week_count,
+ "streak": streak,
+ "totalChars": total_chars,
+ "daily": daily,
+ }
+ )
+ finally:
+ conn.close()
diff --git a/server/requirements.txt b/server/requirements.txt
new file mode 100644
index 0000000..af266d2
--- /dev/null
+++ b/server/requirements.txt
@@ -0,0 +1 @@
+Flask==3.1.3
diff --git a/server/settings_api.py b/server/settings_api.py
new file mode 100644
index 0000000..038d5b9
--- /dev/null
+++ b/server/settings_api.py
@@ -0,0 +1,94 @@
+import json
+
+from flask import Blueprint, jsonify, request
+
+from .auth import login_required
+from .db import get_db, now_iso
+from .translate_service import perform_translate
+
+settings_bp = Blueprint("settings", __name__)
+
+
+def get_user_settings(user_id):
+ conn = get_db()
+ try:
+ row = conn.execute(
+ "SELECT translator, api_url, api_key, extra_config, updated_at FROM settings WHERE user_id = ?",
+ (user_id,),
+ ).fetchone()
+ if row is None:
+ return {
+ "translator": "local",
+ "api_url": "",
+ "api_key": "",
+ "extra_config": {},
+ "updated_at": "",
+ }
+ result = dict(row)
+ try:
+ result["extra_config"] = json.loads(result["extra_config"] or "{}")
+ except Exception:
+ result["extra_config"] = {}
+ return result
+ finally:
+ conn.close()
+
+
+@settings_bp.get("/api/settings/translation")
+@login_required
+def get_translation_settings(user):
+ return jsonify(get_user_settings(user["id"]))
+
+
+@settings_bp.put("/api/settings/translation")
+@login_required
+def update_translation_settings(user):
+ data = request.get_json(silent=True) or {}
+ translator = (data.get("translator") or "local").strip().lower()
+ if translator not in ("local", "mymemory", "custom"):
+ return jsonify({"error": "不支持的翻译方式"}), 400
+ extra_config = data.get("extra_config") or {}
+ if not isinstance(extra_config, dict):
+ return jsonify({"error": "extra_config 必须是对象"}), 400
+ conn = get_db()
+ try:
+ conn.execute(
+ """
+ INSERT INTO settings (user_id, translator, api_url, api_key, extra_config, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)
+ ON CONFLICT(user_id) DO UPDATE SET
+ translator = excluded.translator,
+ api_url = excluded.api_url,
+ api_key = excluded.api_key,
+ extra_config = excluded.extra_config,
+ updated_at = excluded.updated_at
+ """,
+ (
+ user["id"],
+ translator,
+ (data.get("api_url") or "").strip(),
+ (data.get("api_key") or "").strip(),
+ json.dumps(extra_config, ensure_ascii=False),
+ now_iso(),
+ ),
+ )
+ conn.commit()
+ return jsonify(get_user_settings(user["id"]))
+ finally:
+ conn.close()
+
+
+@settings_bp.post("/api/translate")
+@login_required
+def translate_text(user):
+ data = request.get_json(silent=True) or {}
+ text = (data.get("text") or "").strip()
+ if not text:
+ return jsonify({"error": "翻译内容不能为空"}), 400
+ source = (data.get("source") or "zh").strip()
+ target = (data.get("target") or "es").strip()
+ settings = get_user_settings(user["id"])
+ result = perform_translate(text, settings, source, target)
+ if not result.get("ok"):
+ return jsonify({"error": result.get("error", "翻译失败")}), 502
+ return jsonify(result)
diff --git a/server/translate_service.py b/server/translate_service.py
new file mode 100644
index 0000000..ff96112
--- /dev/null
+++ b/server/translate_service.py
@@ -0,0 +1,83 @@
+import json
+import urllib.parse
+import urllib.request
+
+
+def translate_mymemory(text, source="zh-CN", target="es"):
+ url = (
+ "https://api.mymemory.translated.net/get?"
+ + urllib.parse.urlencode({"q": text, "langpair": source + "|" + target})
+ )
+ try:
+ with urllib.request.urlopen(url, timeout=15) as resp:
+ data = json.loads(resp.read().decode("utf-8"))
+ translated = data.get("responseData", {}).get("translatedText", "")
+ if not translated or data.get("quotaFinished"):
+ return {"ok": False, "error": "MyMemory 配额已用完或未返回结果"}
+ return {"ok": True, "translatedText": translated, "provider": "mymemory"}
+ except Exception as exc:
+ return {"ok": False, "error": str(exc)}
+
+
+def translate_custom(text, settings, source="zh", target="es"):
+ api_url = (settings.get("api_url") or "").strip()
+ if not api_url:
+ return {"ok": False, "error": "未配置自定义翻译接口地址"}
+
+ extra = {}
+ try:
+ extra = json.loads(settings.get("extra_config") or "{}")
+ except Exception:
+ extra = {}
+
+ payload = extra.get("payload", {})
+ payload["q"] = text
+ payload["source"] = source
+ payload["target"] = target
+
+ headers = {"Content-Type": "application/json"}
+ api_key = settings.get("api_key") or ""
+ header_key = extra.get("api_key_header", "Authorization")
+ if api_key:
+ headers[header_key] = api_key
+ headers.update(extra.get("headers", {}) or {})
+
+ request = urllib.request.Request(
+ api_url,
+ data=json.dumps(payload).encode("utf-8"),
+ headers=headers,
+ method="POST",
+ )
+ try:
+ with urllib.request.urlopen(request, timeout=20) as resp:
+ data = json.loads(resp.read().decode("utf-8"))
+ text_key = extra.get("result_key", "translatedText")
+ translated = data
+ for part in text_key.split("."):
+ if isinstance(translated, dict):
+ translated = translated.get(part, "")
+ else:
+ translated = ""
+ if not translated:
+ return {"ok": False, "error": "自定义接口未返回可识别的翻译字段"}
+ return {"ok": True, "translatedText": translated, "provider": "custom"}
+ except Exception as exc:
+ return {"ok": False, "error": str(exc)}
+
+
+def perform_translate(text, settings, source="zh", target="es"):
+ provider = (settings.get("translator") or "local").lower()
+ if provider == "mymemory":
+ lang_map = {
+ ("zh", "es"): ("zh-CN", "es"),
+ ("es", "zh"): ("es", "zh-CN"),
+ ("zh", "en"): ("zh-CN", "en"),
+ ("en", "zh"): ("en", "zh-CN"),
+ ("es", "en"): ("es", "en"),
+ ("en", "es"): ("en", "es"),
+ }
+ src, dst = lang_map.get((source, target), (source, target))
+ return translate_mymemory(text, src, dst)
+ if provider == "custom":
+ return translate_custom(text, settings, source, target)
+ return {"ok": False, "error": "本地词典翻译请在前端完成"}
diff --git a/server/vocab_api.py b/server/vocab_api.py
new file mode 100644
index 0000000..45d569a
--- /dev/null
+++ b/server/vocab_api.py
@@ -0,0 +1,90 @@
+from flask import Blueprint, jsonify, request
+
+from .auth import login_required
+from .db import get_db, now_iso
+
+vocab_bp = Blueprint("vocabulary", __name__)
+
+
+def row_to_dict(row):
+ return dict(row)
+
+
+@vocab_bp.get("/api/vocabulary")
+@login_required
+def list_vocabulary(user):
+ conn = get_db()
+ try:
+ rows = conn.execute(
+ """
+ SELECT id, word, english, chinese, category, created_at
+ FROM vocabulary
+ WHERE user_id = ?
+ ORDER BY created_at DESC
+ """,
+ (user["id"],),
+ ).fetchall()
+ return jsonify([row_to_dict(r) for r in rows])
+ finally:
+ conn.close()
+
+
+@vocab_bp.post("/api/vocabulary")
+@login_required
+def add_vocabulary(user):
+ data = request.get_json(silent=True) or {}
+ word = (data.get("word") or "").strip()
+ if not word:
+ return jsonify({"error": "单词不能为空"}), 400
+ conn = get_db()
+ try:
+ existing = conn.execute(
+ "SELECT id FROM vocabulary WHERE user_id = ? AND word = ?",
+ (user["id"], word),
+ ).fetchone()
+ if existing:
+ row = conn.execute(
+ "SELECT id, word, english, chinese, category, created_at FROM vocabulary WHERE id = ?",
+ (existing["id"],),
+ ).fetchone()
+ return jsonify(row_to_dict(row))
+ cur = conn.execute(
+ """
+ INSERT INTO vocabulary (user_id, word, english, chinese, category, created_at)
+ VALUES (?, ?, ?, ?, ?, ?)
+ """,
+ (
+ user["id"],
+ word,
+ (data.get("english") or "").strip(),
+ (data.get("chinese") or "").strip(),
+ (data.get("category") or "").strip(),
+ now_iso(),
+ ),
+ )
+ conn.commit()
+ row = conn.execute(
+ "SELECT id, word, english, chinese, category, created_at FROM vocabulary WHERE id = ?",
+ (cur.lastrowid,),
+ ).fetchone()
+ return jsonify(row_to_dict(row)), 201
+ finally:
+ conn.close()
+
+
+@vocab_bp.delete("/api/vocabulary/")
+@login_required
+def delete_vocabulary(user, item_id):
+ 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
+ conn.execute("DELETE FROM vocabulary WHERE id = ?", (item_id,))
+ conn.commit()
+ return jsonify({"ok": True})
+ finally:
+ conn.close()
diff --git a/style.css b/style.css
index 663ae5c..c4d968d 100644
--- a/style.css
+++ b/style.css
@@ -42,6 +42,40 @@ body {
padding: 1rem;
}
+#mobile-nav { display: none; }
+
+/* User area and stats */
+#user-area {
+ display: flex; align-items: center; gap: 0.4rem;
+ padding: 0.6rem 1rem;
+ border-top: 1px solid var(--border);
+ font-size: 0.8rem;
+ flex-shrink: 0;
+}
+#user-area #user-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+#user-area button, #user-area a {
+ background: none; border: 1px solid var(--border); border-radius: 4px;
+ padding: 0.25rem 0.5rem; font-size: 0.75rem; cursor: pointer;
+ color: var(--text-muted); text-decoration: none; white-space: nowrap;
+}
+#user-area button:hover, #user-area a:hover { border-color: var(--accent); color: var(--accent); }
+
+#stats-panel { border-top: 1px solid var(--border); flex-shrink: 0; max-height: 260px; overflow-y: auto; }
+#stats-content { padding: 0.6rem 1rem; font-size: 0.8rem; }
+.stat-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.4rem; margin-top: 0.4rem; }
+.stat-item { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 0.5rem; }
+.stat-item .stat-value { font-size: 1.1rem; font-weight: 700; color: var(--accent); }
+.stat-item .stat-label { font-size: 0.7rem; color: var(--text-muted); }
+.daily-stats { margin-top: 0.5rem; font-size: 0.75rem; color: var(--text-muted); }
+
+.favorite-btn {
+ background: none; border: 1px solid var(--border); border-radius: 4px;
+ padding: 0.15rem 0.4rem; font-size: 0.75rem; cursor: pointer;
+ color: var(--text-muted); line-height: 1;
+}
+.favorite-btn.active { border-color: #f59f00; color: #f59f00; }
+.favorite-btn:hover { border-color: #f59f00; }
+
#app {
display: grid;
grid-template-columns: 240px 1fr 280px;
@@ -272,10 +306,61 @@ body {
opacity: 0.45; cursor: not-allowed;
}
+/* Translation settings modal */
+#settings-modal-overlay {
+ display: none;
+ position: fixed; inset: 0;
+ background: rgba(0,0,0,0.4);
+ align-items: center; justify-content: center;
+ z-index: 120;
+}
+#settings-modal-overlay.show { display: flex; }
+#settings-modal-box {
+ background: var(--panel-bg); border-radius: var(--radius);
+ padding: 1.5rem; max-width: 480px; width: 90%;
+ box-shadow: 0 8px 24px rgba(0,0,0,0.15);
+ max-height: 90vh; overflow-y: auto;
+}
+#settings-modal-box h3 { font-size: 1rem; margin-bottom: 0.75rem; }
+#settings-modal-box label { display: block; font-size: 0.8rem; color: var(--text-muted); margin: 0.6rem 0 0.25rem; }
+#settings-modal-box select, #settings-modal-box input, #settings-modal-box textarea {
+ width: 100%; border: 1px solid var(--border); border-radius: 6px;
+ padding: 0.45rem 0.6rem; font-size: 0.85rem;
+ background: var(--bg); color: var(--text); outline: none;
+}
+#settings-modal-box select:focus, #settings-modal-box input:focus, #settings-modal-box textarea:focus {
+ border-color: var(--accent);
+}
+#settings-modal-box .modal-actions { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1rem; }
+#settings-modal-box .modal-actions button {
+ padding: 0.4rem 1rem; border-radius: 6px; border: 1px solid var(--border);
+ cursor: pointer; font-size: 0.85rem;
+}
+#settings-modal-box .btn-confirm { background: var(--accent); color: #fff; border-color: var(--accent); }
+
@media (max-width: 768px) {
- body { padding: 0.5rem; }
- #app { grid-template-columns: 1fr; }
- #panel-list { max-height: 30vh; }
- #panel-editor { min-height: 40vh; }
- #panel-translate { max-height: 30vh; }
+ 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); }
+
+ #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, body[data-view="editor"] #stats-panel { display: none; }
+ body[data-view="translate"] #panel-translate { display: flex; }
+ body[data-view="translate"] #panel-list, body[data-view="translate"] #panel-editor, body[data-view="translate"] #stats-panel { display: none; }
+ body[data-view="mine"] #stats-panel { display: flex; }
+ body[data-view="mine"] #panel-list { display: flex; }
+ body[data-view="mine"] #entry-list, body[data-view="mine"] #grammar-quick { display: none; }
}