From 55705683ff8e93a1f360fc85415c20e8e237257c Mon Sep 17 00:00:00 2001 From: guoqingshan Date: Sat, 1 Aug 2026 17:46:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=98=B6=E6=AE=B52=20Python=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E3=80=81=E7=94=A8=E6=88=B7=E7=AE=A1=E7=90=86=E3=80=81?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E3=80=81=E5=8D=95=E8=AF=8D=E6=94=B6=E8=97=8F?= =?UTF-8?q?=E4=B8=8E=E5=93=8D=E5=BA=94=E5=BC=8F=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 53 ++- admin.html | 149 ++++++++ api.js | 101 +++++ app.js | 269 ++++++++++++-- docs/2026-07-31-spanish-diary-design.md | 347 ++++++++++++++++++ index.html | 45 +++ login.html | 92 +++++ server/__init__.py | 1 + server/__pycache__/__init__.cpython-311.pyc | Bin 0 -> 267 bytes server/__pycache__/app.cpython-311.pyc | Bin 0 -> 12756 bytes server/__pycache__/auth.cpython-311.pyc | Bin 0 -> 4425 bytes server/__pycache__/db.cpython-311.pyc | Bin 0 -> 4008 bytes .../__pycache__/entries_api.cpython-311.pyc | Bin 0 -> 10447 bytes .../__pycache__/settings_api.cpython-311.pyc | Bin 0 -> 5556 bytes .../translate_service.cpython-311.pyc | Bin 0 -> 5317 bytes server/__pycache__/vocab_api.cpython-311.pyc | Bin 0 -> 5232 bytes server/app.py | 201 ++++++++++ server/auth.py | 84 +++++ server/data/spanish_diary.db | Bin 0 -> 36864 bytes server/db.py | 93 +++++ server/entries_api.py | 173 +++++++++ server/requirements.txt | 1 + server/settings_api.py | 94 +++++ server/translate_service.py | 83 +++++ server/vocab_api.py | 90 +++++ style.css | 95 ++++- 26 files changed, 1929 insertions(+), 42 deletions(-) create mode 100644 admin.html create mode 100644 api.js create mode 100644 docs/2026-07-31-spanish-diary-design.md create mode 100644 login.html create mode 100644 server/__init__.py create mode 100644 server/__pycache__/__init__.cpython-311.pyc create mode 100644 server/__pycache__/app.cpython-311.pyc create mode 100644 server/__pycache__/auth.cpython-311.pyc create mode 100644 server/__pycache__/db.cpython-311.pyc create mode 100644 server/__pycache__/entries_api.cpython-311.pyc create mode 100644 server/__pycache__/settings_api.cpython-311.pyc create mode 100644 server/__pycache__/translate_service.cpython-311.pyc create mode 100644 server/__pycache__/vocab_api.cpython-311.pyc create mode 100644 server/app.py create mode 100644 server/auth.py create mode 100644 server/data/spanish_diary.db create mode 100644 server/db.py create mode 100644 server/entries_api.py create mode 100644 server/requirements.txt create mode 100644 server/settings_api.py create mode 100644 server/translate_service.py create mode 100644 server/vocab_api.py 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 + + + + + ← 返回日记 +

用户管理

+
+ +
+

添加用户

+
+ + + + + + +
+
+ +
+

用户列表

+ + + +
ID用户名显示名角色状态创建时间操作
+
+ + + + + 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 = + '
' + + '
' + stats.total + '
总日记
' + + '
' + stats.streak + '
连续天数
' + + '
' + stats.week + '
本周
' + + '
' + stats.month + '
本月
' + + '
' + + '
最近 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 0000000000000000000000000000000000000000..3bed8d3e974aa11e497461078e3052675a8a03a8 GIT binary patch literal 267 zcmZ3^%ge<81cv)_vs8ifV-N=h7@>^MASKfoQW&BbQW%37G?}X8f(sJ!GK(`5Trv}j zDix9vle1IvQWOe+bYgm{o}VV;E%x~Ml>FrQ_>~NwLB{+la<+;IElw>ej&V%M%>-&L zDM~EKFN$%=PcF?(%_}L6an4Ukt%xx)Ff!9MFxNFUh)F8V%t_HLPfgNIEGQ_}EyyWN z&&<<>i^dc~9HE;6azqT!?6TCNnD}_0;U)3$dIgogIBatBQ%ZAE?TXldP5^nQSP)2j SU}j`wyuqMy0fvfLfT931y-rC0 literal 0 HcmV?d00001 diff --git a/server/__pycache__/app.cpython-311.pyc b/server/__pycache__/app.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e231c8da3be80b6cb66978248595cfad07430a2f GIT binary patch literal 12756 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 literal 0 HcmV?d00001 diff --git a/server/__pycache__/auth.cpython-311.pyc b/server/__pycache__/auth.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6216056cafc07356cc05cb429e8701dffb02428b GIT binary patch literal 4425 zcmcgvYit|G5#GDw`*Ea9J(9?gnEFvsmSown+r(0wNQ#`;O(@uuTa+fkPqn?1~u}lGslN` zlHAHb(baLZkGa{|-EU^U`;F6SBTxqa9J_ehO32@_Q7dek&@4#;C>&jU*KxtC}w=iMTvDtC|%_QRGA%`aHt4B+P`95k<))s2HA( zDAQ`|loXd}BqizlA2DDM-Z4!{sjxVqn&XK~SXL4OPE;!fsBrMx_%UeTBq@BSS}xY> zB6ADsx9jaIBuf-lBmt5#*0c$^1=ZX2<`u?Dvdk*l-d035OD36Hu*J7)+e}u`lWk>- zO*YG3wPab5d%muzbnASs`=fu8A(}U)`fCl9t(w}pT$>76R=;{!)voLW$UfYGUFl}X zW$uS0!}tt-)tpu&8kWU5Z(#JD(UEa)xA)Y!v3I@Lt$2U%_UO4$uPl0B^S-WHWhERD zQt~CqXQ4Qe%E4h%M73&`E=xi>C8?H4DJ4uN;*x3yR zy)XBNCem_L?9E6My^&;6=}kt{Q*ykw-WyVq5wJ7eE6Nc%8v@&xBpQmOQ`3FPS=Eg= z71ES~?JzEuJ_nvaToPps#5`H&Tb5>4d5Z6R>{7nOc-Nx$>qtV*6mFTuPnVX z|7Mvr8(T{Jp6k)YXukcmyXMd43jCQOf96X*@C6?z@aK#C`5afW^SOZ)Vf9e4qc1nG z?(i(VyD+vimK!U1+R7$kbzZkE+7=v3j-2-QwUM}+mq!+6muBx<9F1d7at2-V$rEJ{ zc>C#)X}F8rGx8|AT8B?^LOpz7824BUyFsY@Y)jxR5Ix{>5G*2d&7ueD6(-9--eCO# zn5Y-Te<%Dl-UIOwqmSq<1|XW|Jt=gfT0}-<BLGIzle|qrwM~`m6VpRhk;u@A#Cyi0{)y>t1zx~z2<)1#dvGUa?KY8@=viu7`nua5au%})SdMs&2IM=Be zc{+}?bB@Bx&xWJrGeuV?WFhMiqoFN-;MIKliGpXK=o$DdkoUZicf9d*xM^pv!Hph7 zzjZj%J4_FO0wQt;oS6`3I#I_N_PRH5c4}O}III=CClKr#N!Y~ym@15LeHvFAGg!?A z@S?1a24uG+kSv?!C@ZpYM&z=FS}kR+8`we|CQW)`z~Ic79kvyHI@9!a_Rz2c-Dup0 zx@EmZZOx|oU_(W(#{dz6tKRsGSH00c_e&e3ym~q94~(80_n!8Tk5w=GF3h0uNKESS z>L_22SCo}xG%_2m4Az3A$6F@^JzjxIzzM~0B<0gh^ZlWB&W{GXU9b0eb&p<);>Al5 zk#p}?d5A3bXns}Zd?pXJGnm@SkjMDU8fBqwoYI2A4nj2viC9cd(I)Kifbf}AOYJ?? zQri*L(dY{8Mr|HN3kU_zud>6H{B5snONC$@fx&*%`Wyu0?oATW0we-1ddH%p%yLe5 z*+!b4Uw0o|yVPSA9fY;$koAGKA1d;pD!u+&$B{ex?j(vwPyN+W=r~*KIGYRo!U z)Z78|%yVEH5q~Bcj;w5Xh#5Xa{?cU!dC$V5Jk&bEaDE?qYTV~mt&{1vkV+(?3PrrB z#>r?znbF8<<$J0rBhi^TDLvJvz}FO&Q?pdtTg*}_D^6549uZWbxkv}KyP9Hd(YA4o zRPK2sw%=F=V+k=Gl}^$DXySFH{1HT%V;H7H+VlG7KKWLDr>8_t=XZMUlg|83Pub*P hTFRs@IxLK*OzNW5hRwQop$(Pw;+PHlwhL`({|3qKw=)0$ literal 0 HcmV?d00001 diff --git a/server/__pycache__/db.cpython-311.pyc b/server/__pycache__/db.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d4e2ab256eeb5d2ca53275d26b5e6dbf26216f52 GIT binary patch literal 4008 zcmb_e-ESJ%6~6-m3=B9X>txeSyO~WC84{e0?bJ@UNfCi@LXB|>xJ?k!Xv~c9IKa@E zA$GipgsP~uEhO)*wn|iORQJVAn^oJ#zV)I1fLS3e8r4#ys*3u?Hv16mQ_mef%)}q- zsJ(E{%-nnKx##}Qx#yhUdps@#ZTxSAg>&%sPyEqoj`qvT9}@^YLIM(qJW98oM4Eta zOWsnjq%8$&+DhPYYo09F(l!E#Xu;kQv(b^$88Du2$U&JFw?9PZCTR8ydaE7um3EnS zq&sVRpQ1}31>2?-WI}JXwEGb2+=6}60;{06S9t_Sx=)}UqVy5L3AA5u0rd(*68Y(W zW2U&@qEiB|iJDXpbqdpExhVPx{di6+iVDndC0c zi7!g(JSRxJa_@pL%fD+w$Q4eop%lxcLf2v}6P;k<(U~VR&Q=!CdW|Fu?<-ywKi7rrsg9ZZgQc+Z2llnT(J}BY!z8IAM8}I;a zPKHw%cd6?z9anE#@Br(yuHO!pSY7|OIkzfmnpDiSyt{3N?mgAgn7dw`I5wymHd8;U7kDQ!MKP^uq&69i*r(LKVcs}FvK0?#MyRuHXb;{u<;yv#IMf}@v;)hKku5SH8!|~on7vTCr#nC$4s=kCz}`QA zt};L>Xq$ekdgTfAnY%`xty55j-X?q(=;L$@a6MV(dvm^ny3aBX#mxFlxF8cxt8W>>L~ zFiIa#-JTOQPMB3rz{geS>L?Hl;L&RE$#{)EQ>V{VBHM4j`{{?3NSz#LkkqeYzld#l z&)3L6oeXS|fd+k~GPxU5T=y85{s*=@45k9{xy4+I5J>B(z|3y>0677vy&YIHDnr;G z+HQ0=y7gU)qeacqu@&~N{sgN)nzh{6v%)jWdqCVLD%bawX%p<)TRYka4ztz*r5)Ru zHA|j*O&de=!=7-@TGA{ZSI0?o2IFxk?nSg@y^R(LzcZc!hoi(Lhyk2@XL&FQg+w@o zE4Yco^i;d1``HaBI(DkHt4t6U*^J_9c@uJ&xkckiHj%>`Hw1ByygM{5v z!3B&`3Fv)_*v_z(2T~Q#lE?(J4dc;gQj@6Fy)3 z>31(a`sKeqe(ZPa7FpG)0)IyoBn93gp;U;AL=(o9(>?9Gsb&?ar0Mo#_y#)_((PHf zP>?j`xFLE@)UxwXVJkR-7_m;bW%IHs`Yp=acnE8t;u4-|Uuy@1UgKV$g~14(-39{p znzTQh{57?4X^ZsLNMD`w!OJ%7UA1BSoiC_&o{ZI~({<|f7IhlN z*6B5R)l>DXc%JtUJaT{HZn(eym*ual7O(4T1muP5=$dc!T=iVjhMav(J92wht~T8L z>#?<1(_-}wG+pS}cee-5J^i3IFjgNJYYd#)9ys}N^wVh5Y4aZail#k%Un3w*gPcGf z@A~B0Ewbe6_rz1Ccd`Hdzj!HWEfS!petA%BPtq!_8aO-K^Dq+@kh!KOyCHt{sTzU zN)SW?IV;Amf%+=OuYm@)_WD0Zm$uCR=d`Cfz8+i)J|6jO{I|j11fPz4KK|$6AA^6r zTst4BpO4f|vh|Z}jh?8}6BVjquwPlb@|fE2J)Nl0qjh=|hF$LJ;Cf&!@X^xNd!sei orMl}<#nB|syU2>YnE@R~ny5=ClIU-uF6sLYajc1U6XW#%1??cCYXATM literal 0 HcmV?d00001 diff --git a/server/__pycache__/entries_api.cpython-311.pyc b/server/__pycache__/entries_api.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3f9071743c9a834372f86eb97f205943d8b1f08c GIT binary patch literal 10447 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 M^!W^uOEmre0WFI< zp?2lAq&1c+oCpQ0>cgfY#1W_vsSo*Sm6nGpk27c@RwG$LY9Ju>VWTvym8YIN>-Fxe zO|bIP@$8v9bIzP|@40ur?_9s*^|}!puYR058T26ZPjXW}tSgN#k5UL-Mm*xFB#M$N zoupE9lulWqmXtMWr4Vo7tw~$T9<>{7ThftoMx7~F)Meb;lkOA~Whg`=f#w~8=PHfR zHTac|sF!!1M^T^P&9mia)mPqi6?$+Dex*aO^X~KLD%@NvKdQNl`oULVRawIkEAEtHtaY2sp$8<+Jb0#KA888ZD1w5X?shEu8X(<_(1>GqL zvMi>LONrIYfkQj|Eb#mCO?Y}4$yGXYMyDtjnxyhodU$K);HgP8PMK#hzdD~~D{`&z zNl((#TX-7Qi$ye6%^z$KgY;+3VSbZBBS>~tj3#Mw?x!p+Br_HLNit63n6&V=mhuhC zQoP;#CeinRKHabr$-Y%X)QYf^LLw^bUqNZ>88l&e1)ZTLEgSFkPLjn9jj-%J>=-)t z(~*Ir1AU`hc{($=jf-c+SPmykjZ?zeHcprTeJqhlkBi6KxLlTpdFNwsnTxzQ`~$8$ z|J;uU2VNZDa*}{!BF{a;9oB8hOd_7tQP`%tDg=jfkL zg`L<5P2C|(2#Fk7cjJPbIFU&Ux-F5+NP=$V#e}R|$@(UNd=)t6D0rm3o@?Ey!uG3Q;Z+XUQx?NtD zo`eCR18^M@7Nx_=KjI>jL(Y)<6h3l&E zl(+K^kupc+o#XTfLAUk$Si{=Jh%BnB<}8{=X1;?+>(+L)kwy^kJG6_&N=e%WVO(Y8vG3ef-+Pw?4YRaO2*M4<5YtR{T1cx(Q2_kLQL)23{QH zo*Nn+uCQ=uh34A6C8>u^AznUm^re9jE_Aq!Go5Cu;UTVXcqnr8xxUeI_%PQ$%)Ru( z{v)FU+{nPF$thRWHK-GWi9|BT3;fV<+`dD z)z53+sT3yF0V+>TA@B~eMEpdMf)5i6i2x~hgtU~yLM$#NL^15gHh8W(2{9P8iv?P` z4GIr2OX`?2u!snr>TXdI(~=xdCj{I?{E>PH*Aqcjm~P{9sjP$v62S`0q*9`6&=78f zCt(}jNfcJ7-QuOSODZc&7M(;oV63SUM{O>RXMLJ_3T*d~m*!wKuF#amY|bB0n2^eZ zG$y1ldl!4B`wPLw%b_BIpLt(W*j~liTc~Tg-2F!P!jM|GN2}YT)O9Z6 z>7z?b!+i4Xew7JpOc*K<+q2Y)hr>$K_f__Y#vb|Geua%F&PY`e)eJ=xR3e^PPmy+c z{l!KUXqX?p-*s_&aa=J5~RF&AkO}3NU7tx!|4i&Um5gpEiXS>lgX! zo7AQQTGIiQeNtnee0 zq!<7yt|C$1#@p9WvEv&H0jHS?fI?jxq0lL^$y&d(aIf*mY{;Xvx}^Cg@|v=l3Ke?N z23tPsRruVmt(H(z_DTCA`d3plNW0A$eB^sJ-e;!hHJN5frmMY8)@)~?;PnrU;@z-{ zbgN7t(||aF3)CdqtZIK2AjDPvnS>!R3E4LIK>yr_uujEyY1bldOJ05 z=X8W@_rBR;iRJS3dH%{Km2K15w&}qo@74u5|C7bjH&3hHLz?#xcwqt;56&K(=T&Ai zU^eO1(NeGSjqhywTU2wX;HO?Sp(>3B+r z#dM~;BZ?i%mZsI1>Q-W9$>wBC7)ZBfGr*8!mfl51e~fAT+S!xJ@VTUL2oHh^rVdGa zLBj5nq6&zaHvaCScIDCMUBoHovw*fK>zxJEp_tFRzNQ&*!JQveeeIgBeVQrS8>xmO zS`}T0_WpL;ueK?HHkE4EsP+QI7HNv=g1Ml&zOW$Le|{^u>;+xXN)*m@U)fIFf>`qk rQ4GSrK)rXUz-=m^1b3*^PL0}W1Y#(dbP!c#Q$ literal 0 HcmV?d00001 diff --git a/server/__pycache__/translate_service.cpython-311.pyc b/server/__pycache__/translate_service.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f696fb8bf2e8b45bf0e530bad63bbe6fa13237c7 GIT binary patch literal 5317 zcmc&&Yitu&7M}6UcqWcL_sqHHo_pucIp3W>xm*qkuCC7q`b(V@^$*g>UbZ>r{(4|WDMTSUNFB`7 z^g)~1W}p@j`zC#mMMcOCH3u~hwH?uWDX-%mG1Bvp8|rK7>M?Ax;%DZ}lFAN$(!ReE z27@7Jl}P8Vemcv=v#w+XX01^gjch}}YbHg^xa|`3xsvNg?!}lQ%3?J)ew?+Kn`>wc zVy|+SV8koAQQN2(s}|?A`-$J?rLl27%NN#{pm8N9hzmKkfI)Vvx7JpSg9V14)f1Wu z71vWOl)|2{on#JBCuo{FK+*8ixOh>4@f?fzz&sm|(GyUSb*z|Ov*Yr|+I3j{#T+U| zPUIT5?WK^Y*^z|YI$l&QQxJTpi<4v09mzX}r}EvEjEJfVs!`H+ZmS)+gu_ zRA$EBlAfWJeX1H!+Sji2MFMLE1_$JUFdkf^VqZuJ`cxUM3CZf(W3t-$kk-65gMQTz zg1*qPh%bQeNig(f+Thi|fEU^=dS% z`nCo_0i{nyTcfMM%+A4`=E!yT-g@`m@86&K~$LUC=Lb#M+`3F!-{Ma zK~oO-!$>xGEWa9+71iKD7LLdvgY8$sA%hEseMm7_Ec=jQN3yBRz#zkT4G_7myJrWHBQPTV$o1-0|LTom?LBP!qT)-h=ejtYc zO+lb7>Hsax2Asp>7~qNtN5XM{j`5H6S^V*O#e~O>4*`8%f+*4i%xQwgYZ)K-c)^*p-aGFv9mt?R0T(W(1O4V4)Q_CepZQj#~o2VyiHCv-n>)0BPJXgyu z!JfO4+l?^??2JcyQ~(gN7U8)#Q2Esg>$(A)D7N^S-(AJ=0^4~j*8LiH(CSAw^-6P0QWxhHHBqh0x2(2tr6$Jn zHw!pTLN=>zOgy~?*?_iaqWLy2%Dl~}N)u5vs+j{L*7vVE=0=QKpEsEc?)>fA-M2rQxpv{7pPst&`Db^p|MK>G zAI_Zl!_4@^%=^Q*1QZP?Bx3A4><@>I296EcUXK}k#5YJ%IVPJO6Ki-mkRfMRIHW>a zZQ3^&k%!!nK!X9lk7V_=X8t$bI4%zwBJl^9!+n4RWNf&$MAg194h)&f2A>ftm~1tJ z-Mwq?J}-k;l5yII#6BQ{I=V+-8zwJ`;Q&Yw4XVVZ2~-}^Yog6?BR9Fsv~rMV=FIiG z*MBiHa^=p2H#75|`FQm9wZHB&?Ea{th6ivt^!AqGI%1sz+z^Q1YT$7VAxntV9*qD5 zkud>^coC8CFV5i}4n+qdieb;-AzVc?Y6&5`3e3T}sD>k;1VW1H3jw}iQNXX_rEu3< zg2}@+1VvWWK}@^{+ycD08n~569hoIF7l8PJR}ey$%n%5YGH-ZElLfe@ zVuB47@_4gYDOcl0qUFwmm$?Pm0pbeM>+f*t7^c!vOZPGl6#kV@2`Sq}Y%W z8(?rx%=g zIqj}YOXaggl(hVNij(X=5Hh@N)+t&6-?oVtO~AJ?<=O}i0pRQYe*iwu0&~{AdgJD# zrz7R*7!lIt3jyVoRgdxr*RmaRyYt(c|QqB@g|bWckQ$5wral2T(zYMiAQS6#Yh z#l=Gx4kfxLmL_Z3QZ;R79cLZ4Y8I1f4lW*|O=#GwdtM>M`AP;F0_NLAim)rYMg9wD zco4SrU%XptsV{4tTbh_JSJObhrI&24rM_L-zI7$@U9}VV?^d!~SM%SkW(dEQCVcaj z70|f3fFrclv4dr9t}NN!$=uvT6TXuJY5|HQVv=NfUjPvCzX6JfIc|Ozz#9l$M0Ui( z2aqD<7`Ml`#{nGxXUtJshFX(WFW8FNV|*bTAfOJ;#4wL5WG?2lm(o~NJU4Qwnu9?A zFaQn{%T|~Ii3-3U5(%93Niuh=K?f-33?NQ$^;OI%2(xYqUgwT; z9r21pg@Iwg!SBYUT%CGlkmCb2guwWOpLU1%ZJH%{_ZI$t#{ zEsnGCz6tvcds6C5Nu9dTnJ%r=mu}aKcaXyFxDP&4cqbuyfOvT`%^FTXEAVk)jrncC zOtFSICz|`XkY7}D0WMScWnzS6uZKd%Ko~`X@>BSy&?E;eMF9dCgQn9|@v!+zQyx8A h(p0&gEwgqP4Z*}p>Kcj6N_uIDevY1{{v9=)`WN4X{)qqp literal 0 HcmV?d00001 diff --git a/server/__pycache__/vocab_api.cpython-311.pyc b/server/__pycache__/vocab_api.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0100a5a40abca45e8832055f4dfb880459a937f4 GIT binary patch literal 5232 zcmcgwYitu&7QXYaJ@(k1ahwp^1QOEAq1*5j(uhN}UO^+Qqy@w=!xoKA1!Og5I6jJnf@6trP`MZ?SqVl{tIu-*3QI_ zo`W6x_Kc1t5U_E9l zn0@FHVMj?KRbeG8ypTj937-8%@e_>`W*<(Kyt&i`3RUt+%~oy|sVgW+_Ez;V?+R#B zJaOfLYvdAW2(@6%*VT6lU7+4aQ-qHmnEuKkRHUK4GkqtA1zGe9Q!xc{DLN5`ZSo7D z^Kw*DB`5?nX(FavgpwjbSqua4FqWXp+HY!w zL5E_|s6pccsbW8d9n0%za8-IKczzc=>I`Hv=!F$I>#iP}KQz;uBb}CpEa$zRoJ;0N zi;K@Wk-Ki8b$(>_oo5`wJN}Iz`<_RvcdqT~&iS1=53;*n)FHmHp!-)eywi;bGLUm! z8LsOg>+z$z{tYKc%Cv8oV)BRb3@q7vC?`t? zP$d39n)fbF$mkLQuKvp7CQ{YCMLLCNS(B;{W|CCB8ittFBw9qPT;(^SZJZoJ8e8nM z=6F*8A5_X(ZZDTa2H?Y9hL0pUPF4X(1vHe$Rq075X(do|4o8x%oMR0{t!D;Hr4=+! zS(Bv2Y+Lge=u~07@+h;=-Ovb{&elTJJ+6k{Fm?UeEqL4zLpmNa;6WJ_)n-}F<}jJ2|WY7d6L95 zreLYl!=>RrjXrBkR$$2KUz_Of?SY}b!C~R;f#I_iBrCM7a_Uc;*k=;`UeB3#`-X(J zxBPK9_7@LwspH0CO3-M`C3o2W2&u0A1NEd*D<>-Wh ziO--y2}Q*?2PBD8fhgTUQLNx^r4x66N2P#93Own}uuZA`I@^|J+cIpM&hA<{G1HrM z^4Cw#onCk^?QG6Cn|0^56nUTZ-D7=ewmrkPLl2hAA;NO(0k>hozR;Z}_wPc-HgAa_iz|o!<)Mylxohb;CHWaqj%p$@$5g4Y~Li461Lsc{HV_ zo7yu??P;$+ zzI5Y`OyiC;*OuYhR$6qf)S)hP&Dk zS(73V!X6J*Mj@E@^AXMO08GpgjhO{6kq{@4Dd@E}!8N1$qEQ_uRL)rvP&EUUq-J)Q zFB>AgCZ4eX5|it@Gyrec(%70Ls`&$WhfHA@Sd+|p63C>ZO=PNdT)%$66zg0|i7D&@ z(_JOd{!tjt!+piYC6D+@3oI{zWA4X`e}1xb9T?@&AD16}zVdkXH;$}IO0o=k1imgZPg3&IG9I272h7p zsOJ%-u%tA4ccF(qaTwk89fka_hc}$$r~zM{VHpSd_)x)zvS7nJGtt3>c3#08 zVUXfj!Pa@wVFH0@Fd_v422=P~#8|w*OFk?B@3TS2W57q4J`8KYx(YU_g7G7NwJTg6 zneIqTOoXN5$}wob2VM0*o}&nY$f6Ft_U|DQbn`EZw&<_=J!I==4=;?S4y9RthV{=d zIV(Xl02dWA29b_WnlCr&?k#CT$Phx7;BurJH$(P}LL`4>Gk$G_wj7Ncw&5Y0E^pE~ zAx%K_*8Gs4;USP!4A};4IT|+%!awyKpKQ6jMfdDU6P+2NGe5?K$3RvwW-qkm=wd^@ JRl&F6{{T#EGVuTa literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2aa89e818e4409c088cd4b56741009c3daeeae59 GIT binary patch literal 36864 zcmeI)UuYx8836EI%eExvbV^7rA{-)XaL5^7%>J95Swp$nzP-ep z1a_QEx=<{tw=cD+UlWmNx5N5cw7<^IFMG2UPpiz%l{{@fRl6{zX`7K~RYR^VELOZ{ zy=CqC@|KagFe5{sAGW5#qYN?_bcrlhd}~Rrwe1q3VrI?z}XbE{u<>KTjucw`48-%^GcW23zSvc!TtW z?*zHo)ylPnMft+Aw^)fAeU!Nm^!1H@(rEF+)sG7n^|e|Utsl-9uqVxD>tS8)6Q{>W zptVNpbaK`b$2!IpSzTOMTJ=JaA62^9ZY0mE z1I?;2*v^QNbOE1Nw?~qwkZ)B*z1<2$R85MHJF}NC+UuN!#mn9c+Od)G9@~o^)GjV! za;G$$E}TEF-fkqF-wW!kMh8<%!R?Y z&xngLAwhrwMD)P6iQyv=xDGab#xZ9$a+t1T-=H|KEyAc^4hyjGE!*XW!MRKIz%;Q< z4MuDnm`=b5m4!Np>4-S4i!i}~urLddW7u=1ZaUBCr&<)-PUE2*3na1=SEGhWUQ3utohOvcW!OMz|16s1{QLiz-PJ}Se9WDOIF&^ndJ*v zK@uQJF%ph$*Z~i0hMDU#lTzdmi`lmC=$vqyn3$WcYX`nVm}$!(Of22Ulu}ohimvG} z9_WGz5@7ivPB}xf$F$Y#GS*5VE^kg<$PEO>%(qry*`PLgZFaOkgp8x3i ztJVZH5F?J^B0>!!-_J2EE(l>9A>uO;2q8=?h_7S0_PO*59LGhRBV9KwgAhcK8<@xx zvTXsDmBj&LvTa;voM1bUU5b!xW0N>U#&OH1w(em0gDgR`H`Pa{O z21O<_AM6=qOgp-3*?M>U{JrwGi*oKjuYN;=;ICW1s+>`xj-M+$QZ5f4e07aJCu+6E zrAvdv12Z!akrN1xtzUQM#)15J{C{%(|DXInzh8R}v=#(F00ck)1V8`;KmY_l00ck) z1RfZHhlka1^}uzJ;l*?*9k)5?+B+^8f$< literal 0 HcmV?d00001 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; } }