chore: 完成全量功能迭代与部署准备
- 移除冗余依赖包 - 新增账号禁用校验与用户管理能力 - 新增文档下载与管理页面文件展示 - 新增API文档页面与用户管理前端页面 - 重构时区处理与docker-compose部署配置 - 完善测试用例与项目文档
This commit is contained in:
+591
-56
@@ -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 指南 ---------- */
|
||||
|
||||
Reference in New Issue
Block a user