feat: 增加用户修改密码与管理员重置密码
This commit is contained in:
@@ -74,7 +74,9 @@ server/data/spanish_diary.db
|
||||
## 阶段2 功能
|
||||
|
||||
- 用户注册、登录、登出
|
||||
- 用户修改密码
|
||||
- 管理员添加、禁用、删除用户
|
||||
- 管理员重置用户密码
|
||||
- 用户日记数据隔离
|
||||
- 共享知识库
|
||||
- 每用户翻译配置(本地 / MyMemory / 自定义接口)
|
||||
|
||||
+12
-1
@@ -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; });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user