feat: 增加用户修改密码与管理员重置密码

This commit is contained in:
guoqingshan
2026-08-01 21:11:03 +08:00
parent ea9103d765
commit 1e84e8e210
8 changed files with 167 additions and 4 deletions
+2
View File
@@ -74,7 +74,9 @@ server/data/spanish_diary.db
## 阶段2 功能
- 用户注册、登录、登出
- 用户修改密码
- 管理员添加、禁用、删除用户
- 管理员重置用户密码
- 用户日记数据隔离
- 共享知识库
- 每用户翻译配置(本地 / MyMemory / 自定义接口)
+12 -1
View File
@@ -26,7 +26,7 @@
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 { display: flex; gap: 0.35rem; flex-wrap: wrap; max-width: 240px; }
.user-actions button {
border: 1px solid var(--border); background: none; border-radius: 4px;
padding: 0.3rem 0.5rem; cursor: pointer; color: var(--text-muted);
@@ -88,6 +88,7 @@
'<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="reset-password">重置密码</button>' +
'<button data-id="' + u.id + '" class="danger delete-user">删除</button>' +
'</div></td>' +
'</tr>';
@@ -131,6 +132,16 @@
adminMsg.textContent = '用户已删除';
loadUsers();
}).catch(function(err) { adminMsg.textContent = err.message; });
} else if (btn.classList.contains('reset-password')) {
var newPassword = prompt('请输入该用户的新密码(至少 6 位):');
if (newPassword == null) return;
if (newPassword.length < 6) {
adminMsg.textContent = '密码至少 6 位';
return;
}
apiAdminResetPassword(id, newPassword).then(function() {
adminMsg.textContent = '密码已重置';
}).catch(function(err) { adminMsg.textContent = err.message; });
}
});
+8
View File
@@ -40,6 +40,10 @@ function apiLogout() {
return apiFetch('/api/logout', { method: 'POST' });
}
function apiChangePassword(data) {
return apiFetch('/api/change-password', { method: 'POST', body: data });
}
function apiGetEntries() {
return apiFetch('/api/entries');
}
@@ -99,3 +103,7 @@ function apiAdminSetStatus(id, isActive) {
function apiAdminDeleteUser(id) {
return apiFetch('/api/admin/users/' + id, { method: 'DELETE' });
}
function apiAdminResetPassword(id, password) {
return apiFetch('/api/admin/users/' + id + '/password', { method: 'PUT', body: { password: password } });
}
+45
View File
@@ -380,6 +380,7 @@ function renderUserArea() {
if (!currentUser) return;
document.getElementById('user-name').textContent = currentUser.displayName || currentUser.username;
document.getElementById('btn-logout').style.display = '';
document.getElementById('btn-change-password').style.display = '';
if (currentUser.isAdmin) {
document.getElementById('admin-link').style.display = '';
}
@@ -477,6 +478,47 @@ function closeSettingsModal() {
document.getElementById('settings-modal-overlay').classList.remove('show');
}
function openPasswordModal() {
document.getElementById('password-msg').textContent = '';
document.getElementById('password-old').value = '';
document.getElementById('password-new').value = '';
document.getElementById('password-confirm').value = '';
document.getElementById('password-modal-overlay').classList.add('show');
setTimeout(function() { document.getElementById('password-old').focus(); }, 0);
}
function closePasswordModal() {
document.getElementById('password-modal-overlay').classList.remove('show');
}
function savePasswordModal() {
var oldPassword = document.getElementById('password-old').value;
var newPassword = document.getElementById('password-new').value;
var confirmPassword = document.getElementById('password-confirm').value;
var msg = document.getElementById('password-msg');
if (!oldPassword) {
msg.textContent = '请输入当前密码';
return;
}
if (newPassword.length < 6) {
msg.textContent = '新密码至少 6 位';
return;
}
if (newPassword !== confirmPassword) {
msg.textContent = '两次输入的新密码不一致';
return;
}
apiChangePassword({
oldPassword: oldPassword,
newPassword: newPassword
}).then(function() {
closePasswordModal();
alert('密码已修改');
}).catch(function(err) {
msg.textContent = err.message || '修改密码失败';
});
}
function saveSettingsModal() {
var extra = {};
var rawExtra = document.getElementById('extra-config').value.trim();
@@ -530,6 +572,9 @@ function bindAppEvents() {
document.getElementById('btn-settings').addEventListener('click', openSettingsModal);
document.getElementById('settings-cancel').addEventListener('click', closeSettingsModal);
document.getElementById('settings-save').addEventListener('click', saveSettingsModal);
document.getElementById('btn-change-password').addEventListener('click', openPasswordModal);
document.getElementById('password-cancel').addEventListener('click', closePasswordModal);
document.getElementById('password-save').addEventListener('click', savePasswordModal);
document.getElementById('btn-logout').addEventListener('click', handleLogout);
document.querySelectorAll('#mobile-nav button').forEach(function(btn) {
+19
View File
@@ -50,6 +50,7 @@
<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-change-password" style="display:none" title="修改密码">🔑 修改密码</button>
<button id="btn-logout" style="display:none">退出</button>
</div>
</aside>
@@ -155,6 +156,24 @@
</div>
</div>
<!-- Change password modal -->
<div id="password-modal-overlay">
<div id="password-modal-box">
<h3>修改密码</h3>
<label for="password-old">当前密码</label>
<input type="password" id="password-old" autocomplete="current-password">
<label for="password-new">新密码</label>
<input type="password" id="password-new" autocomplete="new-password">
<label for="password-confirm">确认新密码</label>
<input type="password" id="password-confirm" autocomplete="new-password">
<div id="password-msg"></div>
<div class="modal-actions">
<button class="btn-cancel" id="password-cancel">取消</button>
<button class="btn-confirm" id="password-save">确认修改</button>
</div>
</div>
</div>
<script src="api.js"></script>
<script src="db.js"></script>
<script src="b1-vocab.js"></script>
+33 -1
View File
@@ -7,7 +7,7 @@ ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if ROOT_DIR not in sys.path:
sys.path.insert(0, ROOT_DIR)
from server.auth import admin_required, authenticate, create_user, login_required
from server.auth import admin_required, authenticate, create_user, login_required, set_password
from server.db import BASE_DIR, get_db, init_db
from server.entries_api import entries_bp
from server.settings_api import settings_bp
@@ -80,6 +80,19 @@ def create_app():
session.clear()
return jsonify({"ok": True})
@app.post("/api/change-password")
@login_required
def change_password(user):
data = request.get_json(silent=True) or {}
old_password = data.get("oldPassword") or ""
new_password = data.get("newPassword") or ""
if len(new_password) < 6:
return jsonify({"error": "新密码至少 6 位"}), 400
if authenticate(user["username"], old_password) is None:
return jsonify({"error": "当前密码不正确"}), 400
set_password(user["id"], new_password)
return jsonify({"ok": True})
@app.get("/api/me")
@login_required
def me(user):
@@ -185,6 +198,25 @@ def create_app():
finally:
conn.close()
@app.put("/api/admin/users/<int:user_id>/password")
@admin_required
def admin_reset_password(user, user_id):
data = request.get_json(silent=True) or {}
new_password = data.get("password") or data.get("newPassword") or ""
if len(new_password) < 6:
return jsonify({"error": "新密码至少 6 位"}), 400
conn = get_db()
try:
target = conn.execute(
"SELECT id FROM users WHERE id = ?", (user_id,)
).fetchone()
finally:
conn.close()
if target is None:
return jsonify({"error": "用户不存在"}), 404
set_password(user_id, new_password)
return jsonify({"ok": True})
@app.get("/")
def index():
return send_from_directory(BASE_DIR, "index.html")
+12
View File
@@ -71,6 +71,18 @@ def create_user(username, password, display_name, is_admin=False, is_active=True
conn.close()
def set_password(user_id, new_password):
conn = get_db()
try:
conn.execute(
"UPDATE users SET password_hash = ? WHERE id = ?",
(generate_password_hash(new_password), user_id),
)
conn.commit()
finally:
conn.close()
def authenticate(username, password):
conn = get_db()
try:
+36 -2
View File
@@ -46,13 +46,13 @@ body {
/* User area and stats */
#user-area {
display: flex; align-items: center; gap: 0.4rem;
display: flex; flex-wrap: wrap; 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 #user-name { flex: 1 1 100%; 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;
@@ -338,6 +338,40 @@ body {
}
#settings-modal-box .btn-confirm { background: var(--accent); color: #fff; border-color: var(--accent); }
/* Change password modal */
#password-modal-overlay {
display: none;
position: fixed; inset: 0;
background: rgba(0,0,0,0.4);
align-items: center; justify-content: center;
z-index: 130;
}
#password-modal-overlay.show { display: flex; }
#password-modal-box {
background: var(--panel-bg); border-radius: var(--radius);
padding: 1.5rem; max-width: 420px; width: 90%;
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
}
#password-modal-box h3 { font-size: 1rem; margin-bottom: 0.75rem; }
#password-modal-box label { display: block; font-size: 0.8rem; color: var(--text-muted); margin: 0.6rem 0 0.25rem; }
#password-modal-box input {
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;
}
#password-modal-box input:focus { border-color: var(--accent); }
#password-msg {
min-height: 1.4em; font-size: 0.8rem; color: var(--danger);
margin-top: 0.6rem;
}
#password-modal-box .modal-actions { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1rem; }
#password-modal-box .modal-actions button {
padding: 0.4rem 1rem; border-radius: 6px; border: 1px solid var(--border);
cursor: pointer; font-size: 0.85rem;
}
#password-modal-box .btn-cancel { background: var(--panel-bg); color: var(--text); }
#password-modal-box .btn-confirm { background: var(--accent); color: #fff; border-color: var(--accent); }
@media (max-width: 768px) {
body { padding: 0; }
#mobile-nav {