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 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; } }