chore: 完成全量功能迭代与部署准备

- 移除冗余依赖包
- 新增账号禁用校验与用户管理能力
- 新增文档下载与管理页面文件展示
- 新增API文档页面与用户管理前端页面
- 重构时区处理与docker-compose部署配置
- 完善测试用例与项目文档
This commit is contained in:
2026-08-01 00:02:42 +08:00
parent f92eff6f65
commit 6c6f690788
19 changed files with 2000 additions and 485 deletions
+4 -1
View File
@@ -80,9 +80,12 @@ async def _resolve_user(authorization: str | None) -> tuple[UserRecord, str]:
async def get_current_user(authorization: str | None = Header(None)) -> UserRecord:
"""鉴权依赖:校验 Bearer token 并返回当前用户记录
must_change_password 用户被拦截(1006),须先经 POST /auth/password 改密。
禁用用户被拦截(1005);must_change_password 用户被拦截(1006),须先经 POST /auth/password 改密。
注:logout/password 端点经 _resolve_user 自解析,不在此拦截范围内(改密是已登录用户唯一可用接口)。
"""
user, _ = await _resolve_user(authorization)
if not user.enabled:
raise ApiError(1005, "账号已禁用")
if user.must_change_password:
raise ApiError(1006, "首次登录须先修改密码")
return user
+58 -2
View File
@@ -10,12 +10,12 @@ from typing import Any, Literal
import structlog
from fastapi import APIRouter, Depends, Header
from pydantic import BaseModel
from pydantic import BaseModel, model_validator
from app.api import deps
from app.api.response import ApiError, ok
from app.core.sessions import SessionStoreError
from app.core.users import UserExistsError, UserRecord, UserStoreError
from app.core.users import UserExistsError, UserNotFoundError, UserRecord, UserStoreError
logger = structlog.get_logger()
@@ -50,12 +50,26 @@ class PasswordResetRequest(BaseModel):
new_password: str
class UserUpdateRequest(BaseModel):
"""管理员更新用户角色/启用状态(至少一项)"""
role: Literal["admin", "user"] | None = None
enabled: bool | None = None
@model_validator(mode="after")
def _at_least_one(self) -> "UserUpdateRequest":
if self.role is None and self.enabled is None:
raise ValueError("至少需要提供一项更新字段(role 或 enabled")
return self
def _public_user(user: UserRecord) -> dict[str, Any]:
"""用户记录脱敏:剔除 password_hash/salt"""
return {
"username": user.username,
"role": user.role,
"must_change_password": user.must_change_password,
"enabled": user.enabled,
"created_at": user.created_at,
}
@@ -69,6 +83,8 @@ async def login(req: LoginRequest) -> dict[str, Any]:
raise ApiError(2001, "认证服务暂不可用") from exc
if user is None:
raise ApiError(1005, "用户名或密码错误")
if not user.enabled:
raise ApiError(1005, "账号已禁用")
try:
token = await deps._get_session_store().create(user.username, user.role)
except SessionStoreError as exc:
@@ -151,6 +167,46 @@ async def create_user(
return ok(_public_user(user))
@router.patch("/users/{username}")
async def update_user(
username: str,
req: UserUpdateRequest,
admin: UserRecord = Depends(deps.require_admin),
) -> dict[str, Any]:
"""更新用户角色或启用状态
用户不存在 1004;role 非法 1001;最后一个 admin 降级 role 或被禁用 1001
禁用用户时清除其全部 session(已登录态立即失效)。
"""
store = deps._get_user_store()
try:
target = await store.get(username)
if target is None:
raise ApiError(1004, "用户不存在")
# 最后 admin 保护:目标当前是唯一 admin 且本次会使其失去 admin 身份或被禁用
if target.role == "admin" and await store.count_admins() <= 1:
will_lose_admin = (req.role is not None and req.role != "admin") or (req.enabled is False)
if will_lose_admin:
raise ApiError(1001, "禁止降级或禁用最后一个管理员")
updated = await store.update_user(username, role=req.role, enabled=req.enabled)
except UserStoreError as exc:
raise ApiError(2001, "认证服务暂不可用") from exc
except UserNotFoundError as exc:
raise ApiError(1004, "用户不存在") from exc
except ValueError as exc:
raise ApiError(1001, str(exc)) from exc
# 禁用用户 → 清除其全部 session(已登录态立即失效)
if req.enabled is False:
try:
await deps._get_session_store().delete_by_username(username)
except SessionStoreError as exc:
raise ApiError(2001, "认证服务暂不可用") from exc
logger.info(
"管理员更新用户", username=username, operator=admin.username, role=req.role, enabled=req.enabled
)
return ok(_public_user(updated))
@router.post("/users/{username}/password")
async def reset_user_password(
username: str, req: PasswordResetRequest, admin: UserRecord = Depends(deps.require_admin)
+4 -4
View File
@@ -4,7 +4,7 @@
鉴权(get_current_user)以 JWT 自包含信息为主,Redis 不可用时降级可用。
"""
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta, timezone
import bcrypt
import jwt
@@ -67,7 +67,7 @@ def verify_password(password: str, hashed: str) -> bool:
def create_access_token(username: str, role: str) -> tuple[str, int]:
"""签发 JWT,返回 (token, expires_in_seconds)"""
now = datetime.now(UTC)
now = datetime.now(timezone.utc)
expire = now + timedelta(minutes=settings.jwt_expire_minutes)
payload = {
"sub": username,
@@ -136,7 +136,7 @@ class UserStore:
user = StoredUser(
username=username,
role=role,
created_at=datetime.now(UTC),
created_at=datetime.now(timezone.utc),
hashed_password=hash_password(password),
)
try:
@@ -203,7 +203,7 @@ async def get_current_user(
)
# Redis 不可用或用户不存在(可能已删除):降级用 JWT payload
logger.warning("用户存储查询未命中,降级使用 JWT payload", username=username)
return AuthUser(username=username, role=role, created_at=datetime.now(UTC))
return AuthUser(username=username, role=role, created_at=datetime.now(timezone.utc))
async def require_admin(user: AuthUser = Depends(get_current_user)) -> AuthUser:
+44 -4
View File
@@ -11,7 +11,7 @@ import hmac
import json
import re
import secrets
from dataclasses import asdict, dataclass
from dataclasses import asdict, dataclass, field
from datetime import UTC, datetime
from redis import asyncio as redis_async
@@ -41,6 +41,10 @@ class UserStoreError(Exception):
"""用户存储后端读写异常"""
class UserNotFoundError(Exception):
"""用户不存在"""
@dataclass
class UserRecord:
"""用户记录(不含明文密码)"""
@@ -50,7 +54,9 @@ class UserRecord:
password_hash: str # hex
salt: str # hex, 16 字节
must_change_password: bool
created_at: str # UTC ISO8601
enabled: bool = True
# created_at 紧随 enabled 之后;二者均有默认值以满足 dataclass 字段顺序约束
created_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
class UserStore:
@@ -76,6 +82,7 @@ class UserStore:
password: str,
role: str = "user",
must_change_password: bool = False,
enabled: bool = True,
) -> UserRecord:
"""创建用户
@@ -94,6 +101,7 @@ class UserStore:
password_hash=self.hash_password(password, salt),
salt=salt.hex(),
must_change_password=must_change_password,
enabled=enabled,
created_at=datetime.now(UTC).isoformat(),
)
await self._write(record)
@@ -104,7 +112,7 @@ class UserStore:
raw = await self._read_raw(username)
if raw is None:
return None
return UserRecord(**json.loads(raw))
return self._from_raw(raw)
async def list(self) -> list[UserRecord]:
"""列出全部用户(扫描 user:* 键)"""
@@ -115,7 +123,15 @@ class UserStore:
raws = [await self._redis.get(key) async for key in self._redis.scan_iter(match=f"{_USER_KEY_PREFIX}*")]
except Exception as e:
raise UserStoreError("列出用户失败") from e
return [UserRecord(**json.loads(raw)) for raw in raws if raw is not None]
return [self._from_raw(raw) for raw in raws if raw is not None]
@staticmethod
def _from_raw(raw: str) -> UserRecord:
"""从 JSON 反序列化 UserRecord;存量记录无 enabled 字段时按 True 兼容"""
data = json.loads(raw)
if "enabled" not in data:
data["enabled"] = True
return UserRecord(**data)
async def delete(self, username: str) -> bool:
"""删除用户,返回是否删除成功(幂等:不存在返回 False)"""
@@ -158,6 +174,30 @@ class UserStore:
"""统计 admin 角色用户数"""
return sum(1 for record in await self.list() if record.role == "admin")
async def update_user(
self,
username: str,
*,
role: str | None = None,
enabled: bool | None = None,
) -> UserRecord:
"""更新用户角色或启用状态
用户不存在抛 UserNotFoundErrorrole 非 admin/user 抛 ValueError。
role/enabled 为 None 表示不修改对应字段。更新后写回并返回最新记录。
"""
record = await self.get(username)
if record is None:
raise UserNotFoundError(f"用户不存在: {username}")
if role is not None and role not in ("admin", "user"):
raise ValueError(f"非法角色: {role!r}(须为 admin/user")
if role is not None:
record.role = role
if enabled is not None:
record.enabled = enabled
await self._write(record)
return record
async def _read_raw(self, username: str) -> str | None:
if self._redis is None:
return self._memory.get(username)
+591 -56
View File
@@ -107,6 +107,10 @@
.tag { display: inline-block; background: #eff6ff; color: #1d4ed8; border-radius: 3px; padding: 1px 6px; font-size: 12px; margin-right: 4px; }
.fallback-flag { color: #b45309; background: #fffbeb; border: 1px solid #fcd34d; border-radius: 4px; padding: 6px 10px; font-size: 13px; margin-bottom: 10px; }
.toolbar { margin: 12px 0; }
.toolbar.users-toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.users-toolbar input[type="text"], .users-toolbar select { width: auto; }
.users-toolbar input[type="text"] { min-width: 200px; }
.user-role-select { width: auto; display: inline-block; margin-right: 4px; }
.muted { color: #6b7280; font-size: 12px; }
.status-badge {
display: inline-block; border-radius: 3px; padding: 1px 8px;
@@ -133,6 +137,20 @@
box-shadow: 0 6px 20px rgba(0,0,0,0.25);
}
.login-box h2 { font-size: 16px; margin: 0 0 16px; }
/* 通用 overlay 弹窗组件:固定全屏遮罩 + 居中卡片 */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.45);
display: flex; align-items: center; justify-content: center; z-index: 200;
}
.modal-card {
background: #fff; border-radius: 8px; padding: 20px 24px; width: 380px;
max-width: calc(100vw - 32px); box-shadow: 0 6px 20px rgba(0,0,0,0.25);
}
.modal-card .modal-title { font-size: 16px; margin: 0 0 14px; }
.modal-card .modal-content { font-size: 13px; }
.modal-card .modal-actions { margin-top: 14px; display: flex; gap: 8px; justify-content: flex-end; }
.modal-card .modal-strength-hint { margin-top: -6px; margin-bottom: 12px; font-size: 12px; }
.modal-card .modal-confirm-text { margin-bottom: 10px; }
.extracted-info {
background: #f0f9ff; border: 1px solid #bae6fd; border-radius: 6px;
padding: 10px 12px; margin-bottom: 12px; font-size: 13px;
@@ -227,6 +245,8 @@
</form>
</div>
</div>
<!-- 通用弹窗挂载点:openModal 动态注入 .modal-overlay/.modal-card -->
<div id="modal-root"></div>
<header>
<h1>知识库管理后台</h1>
<div id="user-area" class="user-area hidden">
@@ -242,6 +262,7 @@
<button type="button" data-target="section-search">检索测试台</button>
<button type="button" data-target="section-categories">类目列表</button>
<button type="button" data-target="section-api-guide">API 指南</button>
<button type="button" data-target="section-profile" id="nav-profile">个人中心</button>
</nav>
</header>
<main>
@@ -256,6 +277,32 @@
<div id="overview-categories"></div>
</section>
<section id="section-profile" class="hidden">
<h2>个人中心</h2>
<div class="error-bar hidden" id="error-profile"></div>
<h3 style="font-size:14px;">账号信息</h3>
<div class="cards" id="profile-cards"></div>
<h3 style="font-size:14px; margin-top:24px;">修改密码</h3>
<form id="profile-password-form">
<div class="field">
<label for="profile-old-password">旧密码</label>
<input type="password" id="profile-old-password" name="old_password" required autocomplete="current-password">
</div>
<div class="field">
<label for="profile-new-password">新密码(至少 8 位)</label>
<input type="password" id="profile-new-password" name="new_password" required autocomplete="new-password">
</div>
<div class="field">
<label for="profile-confirm-password">确认新密码</label>
<input type="password" id="profile-confirm-password" name="confirm_password" required autocomplete="new-password">
</div>
<div class="muted" id="profile-strength-hint"></div>
<button type="submit" class="primary" id="btn-profile-password-submit">确认修改</button>
</form>
<h3 style="font-size:14px; margin-top:24px;">会话</h3>
<button type="button" class="action danger" id="btn-profile-logout">退出登录</button>
</section>
<section id="section-docs" class="hidden">
<h2>文档管理</h2>
<div class="error-bar hidden" id="error-docs"></div>
@@ -351,12 +398,19 @@
<section id="section-users" class="hidden">
<h2>用户管理</h2>
<div class="error-bar hidden" id="error-users"></div>
<div class="toolbar">
<div class="toolbar users-toolbar">
<input type="text" id="user-search" placeholder="按用户名搜索" autocomplete="off">
<select id="user-role-filter">
<option value="all" selected>全部角色</option>
<option value="admin">admin</option>
<option value="user">user</option>
</select>
<button type="button" class="action" id="btn-refresh-users">刷新</button>
<span class="muted" id="users-status"></span>
</div>
<table>
<thead>
<tr><th>用户名</th><th>角色</th><th>须改密</th><th>创建时间</th><th>操作</th></tr>
<tr><th>用户名</th><th>角色</th><th>须改密</th><th>创建时间</th><th>启用</th><th>操作</th></tr>
</thead>
<tbody id="users-tbody"></tbody>
</table>
@@ -454,7 +508,8 @@ function showLogin() {
unmountUsersSection();
}
/* 渲染主界面:顶栏用户区 + 角色门禁(仅 admin 挂载用户管理区块) */
/* 渲染主界面:顶栏用户区 + 角色门禁(仅 admin 挂载用户管理区块)
admin 默认进概览,user 角色默认进个人中心(user 无需访问概览统计) */
function enterApp(user) {
document.getElementById("login-overlay").classList.add("hidden");
document.getElementById("password-overlay").classList.add("hidden");
@@ -466,10 +521,11 @@ function enterApp(user) {
loadedOnce = {};
if (user.role === "admin") {
mountUsersSection();
activateSection("section-overview");
} else {
unmountUsersSection();
activateSection("section-profile");
}
activateSection("section-overview");
}
/* 登录/会话校验成功后的统一入口:must_change_password 用户先强制改密 */
@@ -507,14 +563,17 @@ document.getElementById("login-form").addEventListener("submit", function (event
});
});
document.getElementById("btn-logout").addEventListener("click", function () {
/* 退出登录:调用后端 logout 端点,失败不阻塞本地登出(顶栏 + 个人中心共用) */
function doLogout() {
api("/api/v1/auth/logout", { method: "POST" }).catch(function () {
/* 服务端退出失败不阻塞本地登出 */
}).finally(function () {
clearAuth();
showLogin();
});
});
}
document.getElementById("btn-logout").addEventListener("click", doLogout);
/* ---------- 修改密码 ---------- */
@@ -575,6 +634,191 @@ document.getElementById("password-form").addEventListener("submit", function (ev
});
});
/* ---------- 个人中心 ---------- */
/* 进入区块时加载当前账号信息:GET /auth/me 返回 username/role/must_change_password/enabled/created_at */
function loadProfile() {
hideError("error-profile");
api("/api/v1/auth/me").then(function (user) {
renderProfile(user);
}).catch(function (err) { showError("error-profile", err); });
}
/* 账号信息卡片:用户名 / 角色徽章 / 创建时间 / 须改密 / 启用状态 */
function profileCard(label, value, valueClassName) {
var card = el("div", null, "card");
var num = el("div", null, "num");
if (valueClassName) {
var span = el("span", value, valueClassName);
num.appendChild(span);
} else {
num.textContent = value;
}
card.appendChild(num);
card.appendChild(el("div", label, "label"));
return card;
}
function renderProfile(user) {
var container = document.getElementById("profile-cards");
clearChildren(container);
container.appendChild(profileCard("用户名", user.username || ""));
var roleBadgeClass = "role-badge" + (user.role === "admin" ? " role-admin" : "");
container.appendChild(profileCard("角色", user.role || "", roleBadgeClass));
container.appendChild(profileCard("创建时间", user.created_at || ""));
container.appendChild(profileCard("须改密", user.must_change_password ? "是" : "否"));
container.appendChild(profileCard("启用状态", user.enabled ? "启用" : "禁用"));
}
/* 强度提示:复用 Task 3 文案(<8 弱 / ≥8 中 / ≥12 强,纯文案) */
function updateProfileStrength() {
var pwd = document.getElementById("profile-new-password").value || "";
var level = "";
if (pwd.length > 0) {
if (pwd.length < 8) { level = "弱"; }
else if (pwd.length >= 12) { level = "强"; }
else { level = "中"; }
}
document.getElementById("profile-strength-hint").textContent = level ? "密码强度:" + level : "";
}
document.getElementById("profile-new-password").addEventListener("input", updateProfileStrength);
/* 个人中心改密表单:与顶栏改密弹窗同一端点 POST /auth/password
- 两次不一致本地拦截
- 旧密码错误 1005、新密码 <8 位 1001 由后端返回,统一进错误条
- 成功后:更新本地 user 状态(清除 must_change_password)、清空表单、刷新账号信息、成功提示 */
document.getElementById("profile-password-form").addEventListener("submit", function (event) {
event.preventDefault();
hideError("error-profile");
var newPwd = document.getElementById("profile-new-password").value;
if (newPwd !== document.getElementById("profile-confirm-password").value) {
showError("error-profile", { code: "VALIDATION", message: "两次输入的新密码不一致" });
return;
}
var submitBtn = document.getElementById("btn-profile-password-submit");
var origText = submitBtn.textContent;
submitBtn.disabled = true;
submitBtn.textContent = "提交中…";
api("/api/v1/auth/password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
old_password: document.getElementById("profile-old-password").value,
new_password: newPwd
})
}).then(function () {
/* 改密成功:更新本地 user 状态(清除 must_change_password 标志) */
var user = getStoredUser();
if (user && user.must_change_password) {
user.must_change_password = false;
localStorage.setItem(USER_KEY, JSON.stringify(user));
}
/* 清空表单与强度提示 */
document.getElementById("profile-password-form").reset();
document.getElementById("profile-strength-hint").textContent = "";
/* 刷新账号信息展示 */
loadProfile();
alert("密码修改成功");
}).catch(function (err) {
showError("error-profile", err);
}).finally(function () {
submitBtn.disabled = false;
submitBtn.textContent = origText;
});
});
/* 个人中心退出按钮:复用顶栏 doLogout 逻辑 */
document.getElementById("btn-profile-logout").addEventListener("click", doLogout);
/* ---------- 通用 overlay 弹窗组件 ----------
openModal(id, title, contentBuilder):在 #modal-root 下动态构造 .modal-overlay>.modal-card
- 关闭路径:点遮罩 / 点取消按钮 / ESC(提交中 data-submitting=true 时全部禁用)
- 提交 loading 态:setModalSubmitting 禁用按钮 + 文案改「提交中…」
- 动态内容全 textContent,无 innerHTML */
function openModal(id, title, contentBuilder) {
closeModal(id);
var overlay = el("div", null, "modal-overlay");
overlay.id = "modal-" + id;
overlay.setAttribute("data-modal-id", id);
overlay.setAttribute("data-submitting", "false");
var card = el("div", null, "modal-card");
card.appendChild(el("h3", title, "modal-title"));
var content = el("div", null, "modal-content");
contentBuilder(content);
card.appendChild(content);
overlay.appendChild(card);
/* 点遮罩关闭(提交中不可关闭) */
overlay.addEventListener("click", function (e) {
if (e.target === overlay && overlay.getAttribute("data-submitting") !== "true") {
closeModal(id);
}
});
document.getElementById("modal-root").appendChild(overlay);
}
function closeModal(id) {
var existing = document.getElementById("modal-" + id);
if (existing && existing.parentNode) {
existing.parentNode.removeChild(existing);
}
}
function isModalSubmitting(id) {
var overlay = document.getElementById("modal-" + id);
return !!(overlay && overlay.getAttribute("data-submitting") === "true");
}
/* 提交 loading 态:data-submitting 标记 + 按钮禁用 + 文案改「提交中…」;
失败回滚时恢复按钮原始文案与可点状态 */
function setModalSubmitting(id, submitting, submitBtn) {
var overlay = document.getElementById("modal-" + id);
if (!overlay) { return; }
overlay.setAttribute("data-submitting", submitting ? "true" : "false");
if (submitBtn) {
if (submitting) {
if (!submitBtn.getAttribute("data-original-text")) {
submitBtn.setAttribute("data-original-text", submitBtn.textContent);
}
submitBtn.disabled = true;
submitBtn.textContent = "提交中…";
} else {
submitBtn.disabled = false;
var orig = submitBtn.getAttribute("data-original-text");
submitBtn.textContent = orig || submitBtn.textContent;
}
}
}
function showModelError(id, message) {
var overlay = document.getElementById("modal-" + id);
if (!overlay) { return; }
var box = overlay.querySelector(".error-bar");
if (!box) { return; }
box.textContent = message || "";
box.classList.remove("hidden");
}
function hideModelError(id) {
var overlay = document.getElementById("modal-" + id);
if (!overlay) { return; }
var box = overlay.querySelector(".error-bar");
if (!box) { return; }
box.classList.add("hidden");
box.textContent = "";
}
/* ESC 关闭最顶层弹窗(提交中不可关闭) */
document.addEventListener("keydown", function (e) {
if (e.key !== "Escape") { return; }
var modals = document.querySelectorAll(".modal-overlay");
if (modals.length === 0) { return; }
var top = modals[modals.length - 1];
if (top.getAttribute("data-submitting") === "true") { return; }
var mid = top.getAttribute("data-modal-id");
if (mid) { closeModal(mid); }
});
/* 统一 API 封装:自动注入 Authorization: Bearer <token>(登录接口除外),code !== 0 抛错;
1005 未认证/凭证无效 → 清除本地凭证并回登录卡片;1006 权限不足 → 抛给调用方在错误条展示 */
function api(path, options) {
@@ -629,6 +873,7 @@ function activateSection(targetId) {
if (targetId === "section-categories") { loadCategories(); }
if (targetId === "section-users") { loadUsers(); }
if (targetId === "section-api-guide") { renderApiGuide(); }
if (targetId === "section-profile") { loadProfile(); }
}
}
@@ -814,18 +1059,72 @@ function renderNodes(panel, nodes) {
});
}
/* 删除文档弹窗:复用通用弹窗组件,输入 doc_id 匹配后才可提交;
与用户管理删除保持一致的二次确认体验(不再使用原生弹窗) */
function deleteDocument(docId, title) {
if (!confirm("确定删除文档「" + (title || docId) + "」(" + docId + ") 吗?该操作将删除四层集合中的全部数据,不可恢复。")) {
return;
}
hideError("error-docs");
api("/api/v1/documents/" + encodeURIComponent(docId), { method: "DELETE" }).then(function (data) {
document.getElementById("doc-detail").classList.add("hidden");
loadDocuments(true);
loadOverview();
var status = document.getElementById("docs-status");
status.textContent = "已删除 " + (data.deleted_total || 0) + " 条数据";
}).catch(function (err) { showError("error-docs", err); });
openModal("delete-doc", "删除文档", function (content) {
var warn = el("div", null, "fallback-flag");
warn.textContent = "此操作不可恢复,将删除四层集合中的全部数据";
content.appendChild(warn);
var metaRow = el("div", null, "modal-confirm-text");
metaRow.textContent = "文档:" + (title || docId) + "doc_id=" + docId + "";
content.appendChild(metaRow);
var confirmText = el("div", null, "modal-confirm-text");
confirmText.textContent = "请输入 doc_id " + docId + " 以确认";
content.appendChild(confirmText);
var field = el("div", null, "field");
var input = el("input");
input.type = "text";
input.id = "modal-delete-doc-input";
input.setAttribute("autocomplete", "off");
field.appendChild(input);
content.appendChild(field);
var errBox = el("div", null, "error-bar hidden");
errBox.id = "modal-delete-doc-error";
content.appendChild(errBox);
var actions = el("div", null, "modal-actions");
var submitBtn = el("button", "删除", "action danger");
submitBtn.type = "button";
submitBtn.disabled = true;
var cancelBtn = el("button", "取消", "action");
cancelBtn.type = "button";
cancelBtn.addEventListener("click", function () {
if (!isModalSubmitting("delete-doc")) { closeModal("delete-doc"); }
});
input.addEventListener("input", function () {
submitBtn.disabled = (input.value !== docId);
});
submitBtn.addEventListener("click", function () {
if (input.value !== docId) {
showModelError("delete-doc", "输入的 doc_id 不匹配");
return;
}
hideModelError("delete-doc");
setModalSubmitting("delete-doc", true, submitBtn);
api("/api/v1/documents/" + encodeURIComponent(docId), { method: "DELETE" }).then(function (data) {
closeModal("delete-doc");
document.getElementById("doc-detail").classList.add("hidden");
loadDocuments(true);
loadOverview();
var status = document.getElementById("docs-status");
status.textContent = "已删除 " + (data.deleted_total || 0) + " 条数据";
}).catch(function (err) {
showModelError("delete-doc", (err && err.message) ? err.message : String(err));
setModalSubmitting("delete-doc", false, submitBtn);
});
});
actions.appendChild(submitBtn);
actions.appendChild(cancelBtn);
content.appendChild(actions);
setTimeout(function () { input.focus(); }, 0);
});
}
/* ---------- 3. 文档入库(异步任务轮询) ---------- */
@@ -1125,6 +1424,9 @@ function mountUsersSection() {
document.getElementById("nav").appendChild(navBtn);
document.getElementById("btn-refresh-users").addEventListener("click", loadUsers);
document.getElementById("user-create-form").addEventListener("submit", createUser);
/* 搜索框 input + 角色筛选 change:仅本地重新过滤渲染,不发后端请求 */
document.getElementById("user-search").addEventListener("input", applyUsersFilter);
document.getElementById("user-role-filter").addEventListener("change", applyUsersFilter);
}
function unmountUsersSection() {
@@ -1141,33 +1443,139 @@ function loadUsers() {
}).catch(function (err) { showError("error-users", err); });
}
var usersCache = [];
function renderUsers(users) {
usersCache = users || [];
applyUsersFilter();
}
/* 列表搜索筛选:按用户名(包含、不区分大小写)+ 角色下拉过滤,仅本地重新渲染不发请求 */
function applyUsersFilter() {
var tbody = document.getElementById("users-tbody");
clearChildren(tbody);
users.forEach(function (user) {
var tr = el("tr");
tr.appendChild(el("td", user.username));
var roleTd = el("td");
roleTd.appendChild(el("span", user.role, "role-badge" + (user.role === "admin" ? " role-admin" : "")));
tr.appendChild(roleTd);
tr.appendChild(el("td", user.must_change_password ? "是" : "否"));
tr.appendChild(el("td", user.created_at || ""));
var opsTd = el("td");
var resetBtn = el("button", "重置密码", "action");
resetBtn.type = "button";
resetBtn.addEventListener("click", function () { resetUserPassword(user.username); });
var deleteBtn = el("button", "删除", "action danger");
deleteBtn.type = "button";
deleteBtn.addEventListener("click", function () { deleteUser(user.username, user.role); });
opsTd.appendChild(resetBtn);
opsTd.appendChild(deleteBtn);
tr.appendChild(opsTd);
tbody.appendChild(tr);
var keyword = (document.getElementById("user-search").value || "").trim().toLowerCase();
var roleFilter = document.getElementById("user-role-filter").value;
var currentUser = getStoredUser();
usersCache.forEach(function (user) {
if (keyword && (user.username || "").toLowerCase().indexOf(keyword) === -1) { return; }
if (roleFilter !== "all" && user.role !== roleFilter) { return; }
tbody.appendChild(buildUserRow(user, currentUser));
});
}
/* 单行渲染:行内角色 select + 保存按钮、启用开关;当前登录 admin 自身行禁用防自锁降级 */
function buildUserRow(user, currentUser) {
var isSelf = !!(currentUser && currentUser.username === user.username);
var tr = el("tr");
tr.appendChild(el("td", user.username));
/* 角色:行内 select + 保存按钮(自身行 select disabled + 无保存按钮) */
var roleTd = el("td");
var roleSelect = el("select", null, "user-role-select");
var adminOpt = el("option", "admin");
adminOpt.value = "admin";
var userOpt = el("option", "user");
userOpt.value = "user";
roleSelect.appendChild(userOpt);
roleSelect.appendChild(adminOpt);
roleSelect.value = user.role;
if (isSelf) {
roleSelect.disabled = true;
} else {
var saveRoleBtn = el("button", "保存", "action user-role-save");
saveRoleBtn.type = "button";
saveRoleBtn.disabled = true;
saveRoleBtn.addEventListener("click", function () {
updateUserRole(user.username, roleSelect.value, saveRoleBtn);
});
roleSelect.addEventListener("change", function () {
saveRoleBtn.disabled = (roleSelect.value === user.role);
});
roleTd.appendChild(saveRoleBtn);
}
roleTd.appendChild(roleSelect);
tr.appendChild(roleTd);
tr.appendChild(el("td", user.must_change_password ? "是" : "否"));
tr.appendChild(el("td", user.created_at || ""));
/* 启用开关:当前状态文字 + 样式区分;自身行 disabled 不可点 */
var enabledTd = el("td");
var isEnabled = !!user.enabled;
var toggleBtn = el(
"button",
isEnabled ? "启用" : "禁用",
"action user-enabled-toggle" + (isEnabled ? "" : " danger")
);
toggleBtn.type = "button";
if (isSelf) {
toggleBtn.disabled = true;
toggleBtn.title = "不能禁用当前登录账号";
} else {
toggleBtn.addEventListener("click", function () {
toggleUserEnabled(user.username, isEnabled);
});
}
enabledTd.appendChild(toggleBtn);
tr.appendChild(enabledTd);
/* 操作:重置密码 + 删除(均走通用弹窗组件,无 prompt/confirm 调用) */
var opsTd = el("td");
var resetBtn = el("button", "重置密码", "action");
resetBtn.type = "button";
resetBtn.addEventListener("click", function () { resetUserPassword(user.username); });
var deleteBtn = el("button", "删除", "action danger");
deleteBtn.type = "button";
deleteBtn.addEventListener("click", function () { deleteUser(user.username, user.role); });
opsTd.appendChild(resetBtn);
opsTd.appendChild(deleteBtn);
tr.appendChild(opsTd);
return tr;
}
function showUsersStatus(text) {
var span = document.getElementById("users-status");
if (!span) { return; }
span.textContent = text || "";
if (text) {
setTimeout(function () {
if (span.textContent === text) { span.textContent = ""; }
}, 2500);
}
}
/* 行内角色保存:PATCH {role},成功刷新列表 + 状态提示;失败错误条 */
function updateUserRole(username, newRole, btn) {
hideError("error-users");
if (btn) { btn.disabled = true; }
api("/api/v1/auth/users/" + encodeURIComponent(username), {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ role: newRole })
}).then(function () {
showUsersStatus("已更新 " + username + " 的角色");
loadUsers();
}).catch(function (err) {
showError("error-users", err);
if (btn) { btn.disabled = false; }
});
}
/* 行内启用/禁用开关:PATCH {enabled: !当前},成功刷新列表 + 状态提示;失败错误条 */
function toggleUserEnabled(username, currentEnabled) {
hideError("error-users");
api("/api/v1/auth/users/" + encodeURIComponent(username), {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: !currentEnabled })
}).then(function () {
showUsersStatus("已" + (!currentEnabled ? "启用" : "禁用") + " 用户 " + username);
loadUsers();
}).catch(function (err) { showError("error-users", err); });
}
function createUser(event) {
event.preventDefault();
hideError("error-users");
@@ -1186,31 +1594,158 @@ function createUser(event) {
}).catch(function (err) { showError("error-users", err); });
}
/* 重置密码弹窗:新密码 + 确认密码 + 强度提示(<8 弱 / ≥8 中 / ≥12 强,纯文案)+
两次不一致本地拦截;提交按钮 loading 态;成功关闭弹窗 + 刷新列表 + 状态提示 */
function resetUserPassword(username) {
var newPwd = prompt("为用户「" + username + "」设置新密码(至少 8 位):");
if (newPwd === null) { return; }
if (!newPwd) {
showError("error-users", { code: "VALIDATION", message: "新密码不能为空" });
return;
}
hideError("error-users");
api("/api/v1/auth/users/" + encodeURIComponent(username) + "/password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ new_password: newPwd })
}).then(function () {
loadUsers();
}).catch(function (err) { showError("error-users", err); });
openModal("reset-password", "重置用户「" + username + "」密码", function (content) {
var newField = el("div", null, "field");
newField.appendChild(el("label", "新密码(至少 8 位)"));
var newInput = el("input");
newInput.type = "password";
newInput.id = "modal-reset-new";
newInput.setAttribute("autocomplete", "new-password");
newField.appendChild(newInput);
content.appendChild(newField);
var confirmField = el("div", null, "field");
confirmField.appendChild(el("label", "确认新密码"));
var confirmInput = el("input");
confirmInput.type = "password";
confirmInput.id = "modal-reset-confirm";
confirmInput.setAttribute("autocomplete", "new-password");
confirmField.appendChild(confirmInput);
content.appendChild(confirmField);
var hint = el("div", null, "modal-strength-hint muted");
hint.id = "modal-reset-strength";
content.appendChild(hint);
var errBox = el("div", null, "error-bar hidden");
errBox.id = "modal-reset-error";
content.appendChild(errBox);
/* 强度提示:纯文案,<8 弱 / ≥8 中 / ≥12 强 */
function updateStrength() {
var pwd = newInput.value || "";
var level = "";
if (pwd.length > 0) {
if (pwd.length < 8) { level = "弱"; }
else if (pwd.length >= 12) { level = "强"; }
else { level = "中"; }
}
hint.textContent = level ? "密码强度:" + level : "";
}
newInput.addEventListener("input", updateStrength);
var actions = el("div", null, "modal-actions");
var submitBtn = el("button", "确认重置", "primary");
submitBtn.type = "button";
var cancelBtn = el("button", "取消", "action");
cancelBtn.type = "button";
cancelBtn.addEventListener("click", function () {
if (!isModalSubmitting("reset-password")) { closeModal("reset-password"); }
});
submitBtn.addEventListener("click", function () {
var newPwd = newInput.value;
var confirmPwd = confirmInput.value;
if (!newPwd) {
showModelError("reset-password", "新密码不能为空");
return;
}
/* 两次不一致本地拦截 */
if (newPwd !== confirmPwd) {
showModelError("reset-password", "两次输入的新密码不一致");
return;
}
hideModelError("reset-password");
setModalSubmitting("reset-password", true, submitBtn);
api("/api/v1/auth/users/" + encodeURIComponent(username) + "/password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ new_password: newPwd })
}).then(function () {
closeModal("reset-password");
loadUsers();
showUsersStatus("已重置 " + username + " 的密码");
}).catch(function (err) {
showModelError("reset-password", (err && err.message) ? err.message : String(err));
setModalSubmitting("reset-password", false, submitBtn);
});
});
actions.appendChild(submitBtn);
actions.appendChild(cancelBtn);
content.appendChild(actions);
setTimeout(function () { newInput.focus(); }, 0);
});
}
/* 删除用户弹窗:警告文案 + 「请输入用户名 {username} 以确认」+ 文本输入框;
输入 !== username 时删除按钮禁用;匹配后启用;提交按钮 loading 态 */
function deleteUser(username, role) {
if (!confirm("确定删除用户「" + username + "」(角色 " + role + ")吗?该操作不可恢复。")) {
return;
}
hideError("error-users");
api("/api/v1/auth/users/" + encodeURIComponent(username), { method: "DELETE" }).then(function () {
loadUsers();
}).catch(function (err) { showError("error-users", err); });
openModal("delete-user", "删除用户", function (content) {
var warn = el("div", null, "fallback-flag");
warn.textContent = "此操作不可恢复,将清除该用户及其全部会话";
content.appendChild(warn);
var metaRow = el("div", null, "modal-confirm-text");
metaRow.textContent = "目标用户:" + username + "(角色 " + role + "";
content.appendChild(metaRow);
var confirmText = el("div", null, "modal-confirm-text");
confirmText.textContent = "请输入用户名 " + username + " 以确认";
content.appendChild(confirmText);
var field = el("div", null, "field");
var input = el("input");
input.type = "text";
input.id = "modal-delete-input";
input.setAttribute("autocomplete", "off");
field.appendChild(input);
content.appendChild(field);
var errBox = el("div", null, "error-bar hidden");
errBox.id = "modal-delete-error";
content.appendChild(errBox);
var actions = el("div", null, "modal-actions");
var submitBtn = el("button", "删除", "action danger");
submitBtn.type = "button";
/* 初始禁用:输入匹配后才启用 */
submitBtn.disabled = true;
var cancelBtn = el("button", "取消", "action");
cancelBtn.type = "button";
cancelBtn.addEventListener("click", function () {
if (!isModalSubmitting("delete-user")) { closeModal("delete-user"); }
});
/* 输入 !== username 时删除按钮禁用;匹配后启用 */
input.addEventListener("input", function () {
submitBtn.disabled = (input.value !== username);
});
submitBtn.addEventListener("click", function () {
if (input.value !== username) {
showModelError("delete-user", "输入的用户名不匹配");
return;
}
hideModelError("delete-user");
setModalSubmitting("delete-user", true, submitBtn);
api("/api/v1/auth/users/" + encodeURIComponent(username), { method: "DELETE" }).then(function () {
closeModal("delete-user");
loadUsers();
showUsersStatus("已删除用户 " + username);
}).catch(function (err) {
showModelError("delete-user", (err && err.message) ? err.message : String(err));
setModalSubmitting("delete-user", false, submitBtn);
});
});
actions.appendChild(submitBtn);
actions.appendChild(cancelBtn);
content.appendChild(actions);
setTimeout(function () { input.focus(); }, 0);
});
}
/* ---------- 7. API 指南 ---------- */