feat: 阶段2 Python后端、用户管理、统计、单词收藏与响应式适配
This commit is contained in:
@@ -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 / 自定义接口)
|
||||
- 每用户日记统计
|
||||
- 每用户单词收藏
|
||||
- 手机、平板、桌面响应式适配
|
||||
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>用户管理 — Mi Diario</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<style>
|
||||
body { display: block; max-width: 960px; margin: 0 auto; padding: 1rem; }
|
||||
h1 { font-size: 1.4rem; margin: 0.75rem 0; }
|
||||
.admin-card {
|
||||
background: var(--panel-bg); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 1.25rem; margin-bottom: 1rem;
|
||||
}
|
||||
.admin-card h2 { font-size: 1rem; margin-bottom: 0.75rem; }
|
||||
.admin-form { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.5rem; }
|
||||
.admin-form label { font-size: 0.8rem; color: var(--text-muted); display: flex; flex-direction: column; gap: 0.2rem; }
|
||||
.admin-form input, .admin-form select {
|
||||
border: 1px solid var(--border); border-radius: 6px; padding: 0.45rem 0.6rem;
|
||||
background: var(--bg); color: var(--text); font-size: 0.9rem;
|
||||
}
|
||||
.admin-form button {
|
||||
background: var(--accent); color: #fff; border: none; border-radius: 6px;
|
||||
padding: 0.5rem 1rem; cursor: pointer; align-self: end;
|
||||
}
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
|
||||
th, td { border: 1px solid var(--border); padding: 0.5rem 0.6rem; text-align: left; }
|
||||
th { background: var(--bg); }
|
||||
.user-actions { display: flex; gap: 0.35rem; }
|
||||
.user-actions button {
|
||||
border: 1px solid var(--border); background: none; border-radius: 4px;
|
||||
padding: 0.3rem 0.5rem; cursor: pointer; color: var(--text-muted);
|
||||
}
|
||||
.user-actions button:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.user-actions .danger { color: var(--danger); border-color: var(--danger); }
|
||||
.status-active { color: var(--success); }
|
||||
.status-disabled { color: var(--danger); }
|
||||
#admin-msg { min-height: 1.5em; font-size: 0.85rem; color: var(--text-muted); }
|
||||
#back-home { display: inline-block; margin-bottom: 0.75rem; color: var(--accent); text-decoration: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a id="back-home" href="index.html">← 返回日记</a>
|
||||
<h1>用户管理</h1>
|
||||
<div id="admin-msg"></div>
|
||||
|
||||
<div class="admin-card">
|
||||
<h2>添加用户</h2>
|
||||
<div class="admin-form">
|
||||
<label>用户名<input id="new-username" type="text"></label>
|
||||
<label>显示名<input id="new-display" type="text"></label>
|
||||
<label>密码<input id="new-password" type="password"></label>
|
||||
<label>管理员<select id="new-admin"><option value="0">否</option><option value="1">是</option></select></label>
|
||||
<label>启用<select id="new-active"><option value="1">是</option><option value="0">否</option></select></label>
|
||||
<button id="btn-add-user">添加</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-card">
|
||||
<h2>用户列表</h2>
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>创建时间</th><th>操作</th></tr></thead>
|
||||
<tbody id="user-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<script src="api.js"></script>
|
||||
<script>
|
||||
var adminMsg = document.getElementById('admin-msg');
|
||||
function esc(s) {
|
||||
var div = document.createElement('div');
|
||||
div.textContent = s == null ? '' : String(s);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function loadUsers() {
|
||||
apiAdminUsers().then(function(users) {
|
||||
document.getElementById('user-table-body').innerHTML = users.map(function(u) {
|
||||
var statusClass = u.is_active ? 'status-active' : 'status-disabled';
|
||||
var statusText = u.is_active ? '启用' : '禁用';
|
||||
var roleText = u.is_admin ? '管理员' : '普通用户';
|
||||
return '<tr>' +
|
||||
'<td>' + u.id + '</td>' +
|
||||
'<td>' + esc(u.username) + '</td>' +
|
||||
'<td>' + esc(u.display_name) + '</td>' +
|
||||
'<td>' + roleText + '</td>' +
|
||||
'<td class="' + statusClass + '">' + statusText + '</td>' +
|
||||
'<td>' + esc(u.created_at) + '</td>' +
|
||||
'<td><div class="user-actions">' +
|
||||
'<button data-id="' + u.id + '" data-active="' + (u.is_active ? '0' : '1') + '" class="toggle-user">' + (u.is_active ? '禁用' : '启用') + '</button>' +
|
||||
'<button data-id="' + u.id + '" class="danger delete-user">删除</button>' +
|
||||
'</div></td>' +
|
||||
'</tr>';
|
||||
}).join('') || '<tr><td colspan="7">暂无用户</td></tr>';
|
||||
}).catch(function(err) {
|
||||
adminMsg.textContent = err.message;
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('btn-add-user').addEventListener('click', function() {
|
||||
var username = document.getElementById('new-username').value.trim();
|
||||
var display = document.getElementById('new-display').value.trim();
|
||||
var password = document.getElementById('new-password').value;
|
||||
if (!username || !password) { adminMsg.textContent = '用户名和密码不能为空'; return; }
|
||||
apiAdminCreateUser({
|
||||
username: username,
|
||||
displayName: display || username,
|
||||
password: password,
|
||||
isAdmin: document.getElementById('new-admin').value === '1',
|
||||
isActive: document.getElementById('new-active').value === '1'
|
||||
}).then(function() {
|
||||
adminMsg.textContent = '用户已添加';
|
||||
document.getElementById('new-username').value = '';
|
||||
document.getElementById('new-display').value = '';
|
||||
document.getElementById('new-password').value = '';
|
||||
loadUsers();
|
||||
}).catch(function(err) { adminMsg.textContent = err.message; });
|
||||
});
|
||||
|
||||
document.getElementById('user-table-body').addEventListener('click', function(e) {
|
||||
var btn = e.target.closest('button');
|
||||
if (!btn) return;
|
||||
var id = btn.dataset.id;
|
||||
if (btn.classList.contains('toggle-user')) {
|
||||
apiAdminSetStatus(id, btn.dataset.active === '1').then(function() {
|
||||
loadUsers();
|
||||
}).catch(function(err) { adminMsg.textContent = err.message; });
|
||||
} else if (btn.classList.contains('delete-user')) {
|
||||
if (!confirm('确定删除该用户及其全部日记、收藏数据吗?')) return;
|
||||
apiAdminDeleteUser(id).then(function() {
|
||||
adminMsg.textContent = '用户已删除';
|
||||
loadUsers();
|
||||
}).catch(function(err) { adminMsg.textContent = err.message; });
|
||||
}
|
||||
});
|
||||
|
||||
apiGetMe().then(function(me) {
|
||||
if (!me.isAdmin) {
|
||||
location.href = 'index.html';
|
||||
return;
|
||||
}
|
||||
loadUsers();
|
||||
}).catch(function(err) {
|
||||
if (err && err.status === 401) location.href = 'login.html';
|
||||
else adminMsg.textContent = err.message;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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' });
|
||||
}
|
||||
@@ -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
|
||||
? '<button class="favorite-btn ' + isFavorite + '" data-word="' + escHtml(v.word) + '" data-en="' + escHtml(v.english) + '" data-zh="' + escHtml(v.chinese) + '" title="收藏单词">★</button>'
|
||||
: '';
|
||||
return '<div class="vocab-item">' +
|
||||
'<span class="vocab-word">' + escHtml(v.word) + '</span>' +
|
||||
'<button class="vocab-speech" data-word="' + escHtml(v.word) + '" data-lang="es">' + '\uD83D\uDD0A' + '</button>' +
|
||||
@@ -107,6 +139,7 @@ function updateVocabulary(text) {
|
||||
'<button class="vocab-speech" data-word="' + escHtml(v.word) + '" data-lang="en">🔊</button>' +
|
||||
'<span class="vocab-zh">' + escHtml(v.chinese) + '</span>' +
|
||||
'<button class="vocab-speech" data-word="' + escHtml(v.word) + '" data-lang="zh">🔊</button>' +
|
||||
favBtn +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
@@ -138,12 +171,13 @@ function handleSelection() {
|
||||
|
||||
function selectEntry(id) {
|
||||
state.currentId = id;
|
||||
dbGetEntry(id).then(function(entry) {
|
||||
if (entry) {
|
||||
setEditor(entry);
|
||||
renderTranslate(entry);
|
||||
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 =
|
||||
'<div class="stat-grid">' +
|
||||
'<div class="stat-item"><div class="stat-value">' + stats.total + '</div><div class="stat-label">总日记</div></div>' +
|
||||
'<div class="stat-item"><div class="stat-value">' + stats.streak + '</div><div class="stat-label">连续天数</div></div>' +
|
||||
'<div class="stat-item"><div class="stat-value">' + stats.week + '</div><div class="stat-label">本周</div></div>' +
|
||||
'<div class="stat-item"><div class="stat-value">' + stats.month + '</div><div class="stat-label">本月</div></div>' +
|
||||
'</div>' +
|
||||
'<div class="daily-stats">最近 7 天:' + escHtml(dailyHtml) + '</div>' +
|
||||
'<div class="daily-stats">累计西语字符:' + stats.totalChars + '</div>';
|
||||
}).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;
|
||||
@@ -365,24 +527,35 @@ function init() {
|
||||
document.getElementById('helper-confirm').addEventListener('click', confirmHelperInsert);
|
||||
document.getElementById('helper-input').addEventListener('input', updateHelperPreview);
|
||||
|
||||
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);
|
||||
|
||||
@@ -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/<id>/status | 启用/禁用 |
|
||||
| DELETE | /api/admin/users/<id> | 删除用户 |
|
||||
|
||||
#### 日记与统计
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | /api/entries | 当前用户日记列表 |
|
||||
| POST | /api/entries | 新建日记 |
|
||||
| PUT | /api/entries/<id> | 编辑日记 |
|
||||
| DELETE | /api/entries/<id> | 删除日记 |
|
||||
| GET | /api/entries/stats | 当前用户统计 |
|
||||
|
||||
#### 单词本
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | /api/vocabulary | 收藏列表 |
|
||||
| POST | /api/vocabulary | 收藏单词 |
|
||||
| DELETE | /api/vocabulary/<id> | 删除收藏 |
|
||||
|
||||
#### 翻译配置
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| 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 部署
|
||||
|
||||
+45
@@ -7,6 +7,13 @@
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav id="mobile-nav">
|
||||
<button data-view="list" class="active">📋 日记</button>
|
||||
<button data-view="editor">✏️ 编辑</button>
|
||||
<button data-view="translate">🌐 翻译</button>
|
||||
<button data-view="mine">👤 我的</button>
|
||||
</nav>
|
||||
|
||||
<div id="app">
|
||||
<aside id="panel-list">
|
||||
<div class="panel-header">
|
||||
@@ -31,6 +38,20 @@
|
||||
<li><a href="knowledge/comparacion-fonetica.html">三语语音对照</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div id="stats-panel" class="mobile-view-panel" data-view-panel="mine">
|
||||
<div class="panel-header">
|
||||
<h2>📊 我的统计</h2>
|
||||
</div>
|
||||
<div id="stats-content">登录后显示</div>
|
||||
</div>
|
||||
|
||||
<div id="user-area">
|
||||
<span id="user-name">未登录</span>
|
||||
<button id="btn-settings" title="翻译设置">⚙️ 设置</button>
|
||||
<a id="admin-link" href="admin.html" style="display:none">用户管理</a>
|
||||
<button id="btn-logout" style="display:none">退出</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main id="panel-editor">
|
||||
@@ -111,6 +132,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Translation settings modal -->
|
||||
<div id="settings-modal-overlay">
|
||||
<div id="settings-modal-box">
|
||||
<h3>翻译设置</h3>
|
||||
<label for="translator-type">翻译方式</label>
|
||||
<select id="translator-type">
|
||||
<option value="local">本地词典</option>
|
||||
<option value="mymemory">MyMemory 免费接口</option>
|
||||
<option value="custom">自定义接口</option>
|
||||
</select>
|
||||
<label for="api-url">自定义接口地址</label>
|
||||
<input type="text" id="api-url" placeholder="https://example.com/translate">
|
||||
<label for="api-key">接口密钥(可选)</label>
|
||||
<input type="password" id="api-key" placeholder="API Key">
|
||||
<label for="extra-config">扩展配置 JSON(可选)</label>
|
||||
<textarea id="extra-config" rows="3" placeholder='{"result_key":"translatedText","headers":{}}'></textarea>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-cancel" id="settings-cancel">取消</button>
|
||||
<button class="btn-confirm" id="settings-save">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="api.js"></script>
|
||||
<script src="db.js"></script>
|
||||
<script src="b1-vocab.js"></script>
|
||||
<script src="translate.js"></script>
|
||||
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>登录 — Mi Diario 西班牙语日记</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<style>
|
||||
body { align-items: center; justify-content: center; min-height: 100vh; padding: 1rem; }
|
||||
#auth-box {
|
||||
width: 100%; max-width: 380px;
|
||||
background: var(--panel-bg); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); box-shadow: var(--shadow);
|
||||
padding: 1.5rem;
|
||||
}
|
||||
#auth-box h1 { font-size: 1.25rem; margin-bottom: 0.25rem; }
|
||||
#auth-box p { color: var(--text-muted); font-size: 0.85rem; margin-bottom: 1rem; }
|
||||
#auth-box label { display: block; font-size: 0.8rem; color: var(--text-muted); margin-bottom: 0.25rem; }
|
||||
#auth-box input {
|
||||
width: 100%; border: 1px solid var(--border); border-radius: 6px;
|
||||
padding: 0.55rem 0.7rem; font-size: 0.9rem; margin-bottom: 0.75rem;
|
||||
background: var(--bg); color: var(--text); outline: none;
|
||||
}
|
||||
#auth-box input:focus { border-color: var(--accent); }
|
||||
#auth-box button {
|
||||
width: 100%; background: var(--accent); color: #fff; border: none;
|
||||
padding: 0.6rem; border-radius: 6px; font-size: 0.95rem; cursor: pointer;
|
||||
}
|
||||
#auth-box button:hover { background: var(--accent-hover); }
|
||||
#auth-error { color: var(--danger); font-size: 0.85rem; min-height: 1.2em; margin-bottom: 0.5rem; }
|
||||
#auth-switch { margin-top: 1rem; font-size: 0.85rem; text-align: center; color: var(--text-muted); }
|
||||
#auth-switch a { color: var(--accent); cursor: pointer; text-decoration: none; }
|
||||
#display-name-field { display: none; }
|
||||
#back-home { display: block; text-align: center; margin-top: 0.75rem; font-size: 0.8rem; color: var(--text-muted); text-decoration: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="auth-box">
|
||||
<h1 id="auth-title">登录</h1>
|
||||
<p>Mi Diario 西班牙语日记</p>
|
||||
<div id="auth-error"></div>
|
||||
<form id="auth-form">
|
||||
<label for="auth-username">用户名</label>
|
||||
<input id="auth-username" type="text" autocomplete="username" required>
|
||||
<div id="display-name-field">
|
||||
<label for="auth-display-name">显示名</label>
|
||||
<input id="auth-display-name" type="text" autocomplete="nickname">
|
||||
</div>
|
||||
<label for="auth-password">密码</label>
|
||||
<input id="auth-password" type="password" autocomplete="current-password" required>
|
||||
<button type="submit" id="auth-submit">登录</button>
|
||||
</form>
|
||||
<div id="auth-switch"><a id="auth-toggle">还没有账号?注册</a></div>
|
||||
<a id="back-home" href="index.html">返回首页</a>
|
||||
</div>
|
||||
|
||||
<script src="api.js"></script>
|
||||
<script>
|
||||
var isRegister = false;
|
||||
var title = document.getElementById('auth-title');
|
||||
var submit = document.getElementById('auth-submit');
|
||||
var toggle = document.getElementById('auth-toggle');
|
||||
var displayNameField = document.getElementById('display-name-field');
|
||||
var errorBox = document.getElementById('auth-error');
|
||||
|
||||
function setMode(register) {
|
||||
isRegister = register;
|
||||
title.textContent = register ? '注册' : '登录';
|
||||
submit.textContent = register ? '注册并登录' : '登录';
|
||||
toggle.textContent = register ? '已有账号?登录' : '还没有账号?注册';
|
||||
displayNameField.style.display = register ? 'block' : 'none';
|
||||
errorBox.textContent = '';
|
||||
}
|
||||
|
||||
toggle.addEventListener('click', function() { setMode(!isRegister); });
|
||||
|
||||
document.getElementById('auth-form').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
errorBox.textContent = '';
|
||||
var username = document.getElementById('auth-username').value.trim();
|
||||
var password = document.getElementById('auth-password').value;
|
||||
var displayName = document.getElementById('auth-display-name').value.trim();
|
||||
var action = isRegister ? apiRegister(username, password, displayName || username) : apiLogin(username, password);
|
||||
action.then(function() {
|
||||
location.href = 'index.html';
|
||||
}).catch(function(err) {
|
||||
errorBox.textContent = err.message || '操作失败';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
"""Spanish Diary backend package."""
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+201
@@ -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/<int:user_id>/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/<int:user_id>")
|
||||
@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("/<path:file_path>")
|
||||
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)
|
||||
@@ -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()
|
||||
Binary file not shown.
@@ -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()
|
||||
@@ -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/<int:entry_id>")
|
||||
@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/<int:entry_id>")
|
||||
@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()
|
||||
@@ -0,0 +1 @@
|
||||
Flask==3.1.3
|
||||
@@ -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)
|
||||
@@ -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": "本地词典翻译请在前端完成"}
|
||||
@@ -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/<int:item_id>")
|
||||
@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()
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user