diff --git a/README.md b/README.md
index 2e60fbd..3b596d0 100644
--- a/README.md
+++ b/README.md
@@ -74,7 +74,9 @@ server/data/spanish_diary.db
## 阶段2 功能
- 用户注册、登录、登出
+- 用户修改密码
- 管理员添加、禁用、删除用户
+- 管理员重置用户密码
- 用户日记数据隔离
- 共享知识库
- 每用户翻译配置(本地 / MyMemory / 自定义接口)
diff --git a/admin.html b/admin.html
index c410cf3..ea81533 100644
--- a/admin.html
+++ b/admin.html
@@ -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 @@
'
' + esc(u.created_at) + ' | ' +
'' +
'' +
+ '' +
'' +
' | ' +
'';
@@ -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; });
}
});
diff --git a/api.js b/api.js
index 163c5e3..7aec478 100644
--- a/api.js
+++ b/api.js
@@ -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 } });
+}
diff --git a/app.js b/app.js
index fef4f86..ab7c239 100644
--- a/app.js
+++ b/app.js
@@ -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) {
diff --git a/index.html b/index.html
index ead9e9f..0944f4e 100644
--- a/index.html
+++ b/index.html
@@ -50,6 +50,7 @@
未登录
用户管理
+
@@ -155,6 +156,24 @@
+
+
+
+
修改密码
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/server/app.py b/server/app.py
index 572de7f..97151d2 100644
--- a/server/app.py
+++ b/server/app.py
@@ -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//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")
diff --git a/server/auth.py b/server/auth.py
index a82662a..5a0fe45 100644
--- a/server/auth.py
+++ b/server/auth.py
@@ -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:
diff --git a/style.css b/style.css
index c4d968d..5812c76 100644
--- a/style.css
+++ b/style.css
@@ -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 {