feat: 新增用户管理(用户增删改查、密码重置、角色权限、会话认证)与 API 指南
- 新增 app/api/deps.py、app/core/users.py、app/core/sessions.py:会话鉴权依赖、 用户存储(PBKDF2-HMAC-SHA256 + 随机 salt,Redis/内存降级)、会话签发与校验(TTL 12h) - auth.py 新增用户管理端点(列表/创建/重置密码/删除)与 admin/user 角色权限边界, user 访问用户管理返回 1006,禁删自己与最后一个 admin - admin.html 新增用户管理面板(仅 admin 挂载)与 API 指南在线测试台 - Dockerfile 将 uv 放入 PATH;docker-compose 调整 qdrant 依赖为 service_started 并移除依赖 curl 的 healthcheck(官方镜像不含 curl) - 新增用户管理测试(users/sessions/auth_api/auth_integration),全量 461 项测试通过 Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
This commit is contained in:
+691
-23
@@ -79,7 +79,7 @@
|
||||
button.primary:disabled { background: #9ca3af; cursor: not-allowed; }
|
||||
form .field { margin-bottom: 12px; }
|
||||
form label { display: block; font-size: 13px; margin-bottom: 4px; color: #374151; }
|
||||
input[type="text"], input[type="number"], textarea {
|
||||
input[type="text"], input[type="number"], input[type="password"], textarea, select {
|
||||
width: 100%; border: 1px solid #d1d5db; border-radius: 4px;
|
||||
padding: 8px; font-size: 13px; font-family: inherit;
|
||||
}
|
||||
@@ -118,6 +118,12 @@
|
||||
header { position: relative; }
|
||||
.user-area { position: absolute; top: 14px; right: 24px; display: flex; align-items: center; gap: 8px; }
|
||||
.user-area .action { padding: 4px 10px; }
|
||||
.user-area #user-name { color: #f9fafb; font-size: 13px; }
|
||||
.role-badge {
|
||||
display: inline-block; border-radius: 3px; padding: 1px 8px; font-size: 12px;
|
||||
background: #eff6ff; color: #1d4ed8; border: 1px solid #93c5fd;
|
||||
}
|
||||
.role-badge.role-admin { background: #fef3c7; color: #b45309; border-color: #fcd34d; }
|
||||
.login-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.45);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 100;
|
||||
@@ -139,6 +145,45 @@
|
||||
padding: 12px 14px; margin-bottom: 12px; font-size: 13px; white-space: pre-wrap;
|
||||
}
|
||||
.summary-box .sum-title { font-weight: 600; margin-bottom: 6px; color: #15803d; }
|
||||
.method-badge {
|
||||
display: inline-block; border-radius: 3px; padding: 1px 8px;
|
||||
font-size: 12px; font-weight: 600; color: #fff; margin-right: 8px;
|
||||
}
|
||||
.method-get { background: #16a34a; }
|
||||
.method-post { background: #2563eb; }
|
||||
.method-delete { background: #dc2626; }
|
||||
.auth-badge {
|
||||
display: inline-block; border-radius: 3px; padding: 1px 8px;
|
||||
font-size: 12px; margin-left: 8px; border: 1px solid transparent;
|
||||
}
|
||||
.auth-badge.auth-none { background: #f0fdf4; color: #15803d; border-color: #86efac; }
|
||||
.auth-badge.auth-bearer { background: #eff6ff; color: #1d4ed8; border-color: #93c5fd; }
|
||||
.auth-badge.auth-admin { background: #fef3c7; color: #b45309; border-color: #fcd34d; }
|
||||
.api-item { border: 1px solid #e5e7eb; border-radius: 6px; padding: 12px 14px; margin-bottom: 12px; }
|
||||
.api-item .api-head { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; margin-bottom: 6px; }
|
||||
.api-item code { background: #f3f4f6; border-radius: 3px; padding: 1px 6px; font-size: 13px; }
|
||||
.api-item .api-desc { font-size: 13px; color: #374151; margin-bottom: 8px; }
|
||||
.api-item h4 { font-size: 13px; margin: 10px 0 6px; color: #374151; }
|
||||
.json-pre {
|
||||
background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 4px;
|
||||
padding: 8px; white-space: pre-wrap; word-break: break-word;
|
||||
font-size: 12px; max-height: 260px; overflow: auto;
|
||||
}
|
||||
.curl-box { position: relative; }
|
||||
.curl-box .curl-copy { position: absolute; top: 6px; right: 6px; }
|
||||
.curl-pre {
|
||||
background: #1f2937; color: #e5e7eb; border-radius: 4px;
|
||||
padding: 8px; white-space: pre-wrap; word-break: break-all; font-size: 12px;
|
||||
}
|
||||
.try-panel { margin-top: 10px; border-top: 1px dashed #e5e7eb; padding-top: 10px; }
|
||||
.try-panel .field { margin-bottom: 10px; }
|
||||
.try-panel label { display: block; font-size: 13px; margin-bottom: 4px; color: #374151; }
|
||||
.try-panel textarea { min-height: 100px; font-family: ui-monospace, monospace; }
|
||||
.try-result pre {
|
||||
background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 4px;
|
||||
padding: 8px; white-space: pre-wrap; word-break: break-word;
|
||||
font-size: 12px; max-height: 320px; overflow: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -159,11 +204,36 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div id="password-overlay" class="login-overlay hidden">
|
||||
<div class="login-box">
|
||||
<h2 id="password-title">修改密码</h2>
|
||||
<div class="error-bar hidden" id="error-password"></div>
|
||||
<div class="fallback-flag hidden" id="password-forced-tip">首次登录须先修改密码,完成后方可进入管理后台</div>
|
||||
<form id="password-form">
|
||||
<div class="field">
|
||||
<label for="password-old">旧密码</label>
|
||||
<input type="password" id="password-old" name="old_password" required autocomplete="current-password">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password-new">新密码(至少 8 位)</label>
|
||||
<input type="password" id="password-new" name="new_password" required autocomplete="new-password">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password-confirm">确认新密码</label>
|
||||
<input type="password" id="password-confirm" name="confirm_password" required autocomplete="new-password">
|
||||
</div>
|
||||
<button type="submit" class="primary">确认修改</button>
|
||||
<button type="button" class="action" id="btn-password-cancel">取消</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<header>
|
||||
<h1>知识库管理后台</h1>
|
||||
<div id="user-area" class="user-area hidden">
|
||||
<span class="muted" id="user-name"></span>
|
||||
<button type="button" class="action" id="btn-logout">登出</button>
|
||||
<span id="user-name"></span>
|
||||
<span id="user-role" class="role-badge"></span>
|
||||
<button type="button" class="action" id="btn-change-password">修改密码</button>
|
||||
<button type="button" class="action" id="btn-logout">退出登录</button>
|
||||
</div>
|
||||
<nav id="nav">
|
||||
<button type="button" data-target="section-overview" class="active">概览</button>
|
||||
@@ -171,6 +241,7 @@
|
||||
<button type="button" data-target="section-ingest">文档入库</button>
|
||||
<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>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
@@ -268,8 +339,49 @@
|
||||
<tbody id="categories-tbody"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section id="section-api-guide" class="hidden">
|
||||
<h2>API 指南</h2>
|
||||
<div class="muted" style="margin-bottom:12px;">全部端点清单与在线测试台;base URL 取当前站点(location.origin),需鉴权端点发送时自动附带当前登录 token。仅可从清单选择端点,不支持自定义 URL。</div>
|
||||
<div id="api-guide-list"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<template id="tpl-section-users">
|
||||
<section id="section-users" class="hidden">
|
||||
<h2>用户管理</h2>
|
||||
<div class="error-bar hidden" id="error-users"></div>
|
||||
<div class="toolbar">
|
||||
<button type="button" class="action" id="btn-refresh-users">刷新</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>用户名</th><th>角色</th><th>须改密</th><th>创建时间</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody id="users-tbody"></tbody>
|
||||
</table>
|
||||
<h3 style="font-size:14px; margin-top:24px;">创建用户</h3>
|
||||
<form id="user-create-form">
|
||||
<div class="field">
|
||||
<label for="user-new-name">用户名</label>
|
||||
<input type="text" id="user-new-name" name="username" required autocomplete="off">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="user-new-password">初始密码(至少 8 位)</label>
|
||||
<input type="password" id="user-new-password" name="password" required autocomplete="new-password">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="user-new-role">角色</label>
|
||||
<select id="user-new-role" name="role">
|
||||
<option value="user" selected>user</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="primary">创建用户</button>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
"use strict";
|
||||
|
||||
@@ -299,25 +411,68 @@ function hideError(boxId) {
|
||||
document.getElementById(boxId).classList.add("hidden");
|
||||
}
|
||||
|
||||
/* ---------- 认证 token 管理 ---------- */
|
||||
function getToken() { return localStorage.getItem("qmd_token") || ""; }
|
||||
function setToken(t) { localStorage.setItem("qmd_token", t); }
|
||||
function clearToken() { localStorage.removeItem("qmd_token"); }
|
||||
/* ---------- 认证状态管理 ---------- */
|
||||
var TOKEN_KEY = "qmd_token";
|
||||
var USER_KEY = "qmd_user";
|
||||
var passwordForced = false;
|
||||
|
||||
function getToken() { return localStorage.getItem(TOKEN_KEY) || ""; }
|
||||
function getStoredUser() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(USER_KEY) || "null");
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function saveAuth(token, user) {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify({
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
must_change_password: !!user.must_change_password
|
||||
}));
|
||||
}
|
||||
function clearAuth() {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
document.getElementById("login-overlay").classList.remove("hidden");
|
||||
document.getElementById("password-overlay").classList.add("hidden");
|
||||
document.getElementById("user-area").classList.add("hidden");
|
||||
loadedOnce = {};
|
||||
unmountUsersSection();
|
||||
}
|
||||
|
||||
function afterLogin(user) {
|
||||
/* 渲染主界面:顶栏用户区 + 角色门禁(仅 admin 挂载用户管理区块) */
|
||||
function enterApp(user) {
|
||||
document.getElementById("login-overlay").classList.add("hidden");
|
||||
document.getElementById("password-overlay").classList.add("hidden");
|
||||
document.getElementById("user-area").classList.remove("hidden");
|
||||
document.getElementById("user-name").textContent = user.username + " (" + user.role + ")";
|
||||
document.getElementById("user-name").textContent = user.username;
|
||||
var badge = document.getElementById("user-role");
|
||||
badge.textContent = user.role;
|
||||
badge.className = "role-badge" + (user.role === "admin" ? " role-admin" : "");
|
||||
loadedOnce = {};
|
||||
if (user.role === "admin") {
|
||||
mountUsersSection();
|
||||
} else {
|
||||
unmountUsersSection();
|
||||
}
|
||||
activateSection("section-overview");
|
||||
}
|
||||
|
||||
/* 登录/会话校验成功后的统一入口:must_change_password 用户先强制改密 */
|
||||
function afterAuth(user) {
|
||||
if (user.must_change_password) {
|
||||
document.getElementById("login-overlay").classList.add("hidden");
|
||||
openPasswordModal(true);
|
||||
return;
|
||||
}
|
||||
enterApp(user);
|
||||
}
|
||||
|
||||
document.getElementById("login-form").addEventListener("submit", function (event) {
|
||||
event.preventDefault();
|
||||
hideError("error-login");
|
||||
@@ -325,6 +480,7 @@ document.getElementById("login-form").addEventListener("submit", function (event
|
||||
username: document.getElementById("login-username").value,
|
||||
password: document.getElementById("login-password").value
|
||||
};
|
||||
/* 登录接口不携带 Authorization 头 */
|
||||
fetch("/api/v1/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -334,19 +490,84 @@ document.getElementById("login-form").addEventListener("submit", function (event
|
||||
showError("error-login", { code: body.code, message: body.message });
|
||||
return;
|
||||
}
|
||||
setToken(body.data.access_token);
|
||||
afterLogin(body.data.user);
|
||||
var data = body.data;
|
||||
saveAuth(data.token, data);
|
||||
afterAuth(data);
|
||||
}).catch(function (err) {
|
||||
showError("error-login", { code: "NETWORK", message: "登录请求失败: " + (err && err.message ? err.message : err) });
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("btn-logout").addEventListener("click", function () {
|
||||
clearToken();
|
||||
showLogin();
|
||||
api("/api/v1/auth/logout", { method: "POST" }).catch(function () {
|
||||
/* 服务端退出失败不阻塞本地登出 */
|
||||
}).finally(function () {
|
||||
clearAuth();
|
||||
showLogin();
|
||||
});
|
||||
});
|
||||
|
||||
/* 统一 API 封装:自动注入 token,code !== 0 抛错,未认证跳登录 */
|
||||
/* ---------- 修改密码 ---------- */
|
||||
|
||||
function openPasswordModal(forced) {
|
||||
passwordForced = !!forced;
|
||||
document.getElementById("password-title").textContent = forced ? "首次登录须修改密码" : "修改密码";
|
||||
document.getElementById("password-forced-tip").classList.toggle("hidden", !forced);
|
||||
/* 强制改密层无取消按钮、不可跳过 */
|
||||
document.getElementById("btn-password-cancel").classList.toggle("hidden", forced);
|
||||
if (forced) {
|
||||
document.getElementById("login-overlay").classList.add("hidden");
|
||||
}
|
||||
hideError("error-password");
|
||||
document.getElementById("password-form").reset();
|
||||
document.getElementById("password-overlay").classList.remove("hidden");
|
||||
}
|
||||
|
||||
document.getElementById("btn-change-password").addEventListener("click", function () {
|
||||
openPasswordModal(false);
|
||||
});
|
||||
|
||||
document.getElementById("btn-password-cancel").addEventListener("click", function () {
|
||||
if (passwordForced) { return; }
|
||||
document.getElementById("password-overlay").classList.add("hidden");
|
||||
});
|
||||
|
||||
document.getElementById("password-form").addEventListener("submit", function (event) {
|
||||
event.preventDefault();
|
||||
hideError("error-password");
|
||||
var newPwd = document.getElementById("password-new").value;
|
||||
if (newPwd !== document.getElementById("password-confirm").value) {
|
||||
showError("error-password", { code: "VALIDATION", message: "两次输入的新密码不一致" });
|
||||
return;
|
||||
}
|
||||
api("/api/v1/auth/password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
old_password: document.getElementById("password-old").value,
|
||||
new_password: newPwd
|
||||
})
|
||||
}).then(function () {
|
||||
document.getElementById("password-overlay").classList.add("hidden");
|
||||
if (passwordForced) {
|
||||
/* 强制改密成功后才进入主界面 */
|
||||
passwordForced = false;
|
||||
var user = getStoredUser();
|
||||
if (user) {
|
||||
user.must_change_password = false;
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||
enterApp(user);
|
||||
}
|
||||
} else {
|
||||
alert("密码修改成功");
|
||||
}
|
||||
}).catch(function (err) {
|
||||
showError("error-password", err);
|
||||
});
|
||||
});
|
||||
|
||||
/* 统一 API 封装:自动注入 Authorization: Bearer <token>(登录接口除外),code !== 0 抛错;
|
||||
1005 未认证/凭证无效 → 清除本地凭证并回登录卡片;1006 权限不足 → 抛给调用方在错误条展示 */
|
||||
function api(path, options) {
|
||||
options = options || {};
|
||||
options.headers = Object.assign({}, options.headers || {});
|
||||
@@ -355,18 +576,13 @@ function api(path, options) {
|
||||
options.headers["Authorization"] = "Bearer " + token;
|
||||
}
|
||||
return fetch(path, options).then(function (resp) {
|
||||
if (resp.status === 401) {
|
||||
clearToken();
|
||||
showLogin();
|
||||
throw { code: 1003, message: "未认证或登录已过期,请重新登录" };
|
||||
}
|
||||
return resp.json().catch(function () {
|
||||
throw { code: "HTTP " + resp.status, message: "响应解析失败" };
|
||||
});
|
||||
}).then(function (body) {
|
||||
if (body.code !== 0) {
|
||||
if (body.code === 1003 || body.code === 1005) {
|
||||
clearToken();
|
||||
if (body.code === 1005) {
|
||||
clearAuth();
|
||||
showLogin();
|
||||
}
|
||||
throw { code: body.code, message: body.message };
|
||||
@@ -402,6 +618,8 @@ function activateSection(targetId) {
|
||||
if (targetId === "section-overview") { loadOverview(); }
|
||||
if (targetId === "section-docs") { loadDocuments(true); }
|
||||
if (targetId === "section-categories") { loadCategories(); }
|
||||
if (targetId === "section-users") { loadUsers(); }
|
||||
if (targetId === "section-api-guide") { renderApiGuide(); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -870,6 +1088,449 @@ function loadCategories() {
|
||||
}).catch(function (err) { showError("error-categories", err); });
|
||||
}
|
||||
|
||||
/* ---------- 6. 用户管理(仅 admin 渲染) ---------- */
|
||||
|
||||
/* admin 登录时将用户管理区块从 template 挂载进 DOM;非 admin 完全不渲染 */
|
||||
function mountUsersSection() {
|
||||
if (document.getElementById("section-users")) { return; }
|
||||
var tpl = document.getElementById("tpl-section-users");
|
||||
document.querySelector("main").appendChild(tpl.content.cloneNode(true));
|
||||
var navBtn = el("button", "用户管理");
|
||||
navBtn.type = "button";
|
||||
navBtn.id = "nav-users";
|
||||
navBtn.setAttribute("data-target", "section-users");
|
||||
document.getElementById("nav").appendChild(navBtn);
|
||||
document.getElementById("btn-refresh-users").addEventListener("click", loadUsers);
|
||||
document.getElementById("user-create-form").addEventListener("submit", createUser);
|
||||
}
|
||||
|
||||
function unmountUsersSection() {
|
||||
var sec = document.getElementById("section-users");
|
||||
if (sec) { sec.parentNode.removeChild(sec); }
|
||||
var navBtn = document.getElementById("nav-users");
|
||||
if (navBtn) { navBtn.parentNode.removeChild(navBtn); }
|
||||
}
|
||||
|
||||
function loadUsers() {
|
||||
hideError("error-users");
|
||||
api("/api/v1/auth/users").then(function (users) {
|
||||
renderUsers(users || []);
|
||||
}).catch(function (err) { showError("error-users", err); });
|
||||
}
|
||||
|
||||
function renderUsers(users) {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
function createUser(event) {
|
||||
event.preventDefault();
|
||||
hideError("error-users");
|
||||
var payload = {
|
||||
username: document.getElementById("user-new-name").value,
|
||||
password: document.getElementById("user-new-password").value,
|
||||
role: document.getElementById("user-new-role").value
|
||||
};
|
||||
api("/api/v1/auth/users", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
}).then(function () {
|
||||
document.getElementById("user-create-form").reset();
|
||||
loadUsers();
|
||||
}).catch(function (err) { showError("error-users", err); });
|
||||
}
|
||||
|
||||
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); });
|
||||
}
|
||||
|
||||
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); });
|
||||
}
|
||||
|
||||
/* ---------- 7. API 指南 ---------- */
|
||||
|
||||
/* 展示用静态维护清单:与后端路由表无自动同步,
|
||||
测试 tests/test_admin_page.py 校验每个 method+path 均在真实路由集合内防止漂移。
|
||||
auth 取值:none=免登录 / bearer=需登录 / admin=仅 admin */
|
||||
var API_GUIDE = [
|
||||
{ method: "GET", path: "/api/v1/health", desc: "健康检查", auth: "none",
|
||||
query: [], pathParams: [], form: [], body: null, upload: false },
|
||||
{ method: "POST", path: "/api/v1/search", desc: "分层检索(query 路由 → L1→L2→L3 剪枝 → chunk hybrid 检索)", auth: "none",
|
||||
query: [], pathParams: [], form: [],
|
||||
body: { query: "NAS 上如何部署服务?", top_k: 5, summarize: false }, upload: false },
|
||||
{ method: "POST", path: "/api/v1/documents", desc: "文档入库(202 异步入库,返回 task_id)", auth: "bearer",
|
||||
query: [], pathParams: [], form: [],
|
||||
body: { title: "示例文档", source: "manual", text: "文档正文内容……" }, upload: false },
|
||||
{ method: "POST", path: "/api/v1/documents/upload", desc: "multipart 文件上传入库(.txt/.md/.html/.htm/.pdf/.docx,202 异步)", auth: "bearer",
|
||||
query: [], pathParams: [],
|
||||
form: [
|
||||
{ name: "title", desc: "可选标题,默认取文件名去扩展", def: "" },
|
||||
{ name: "source", desc: "可选来源标识,默认 file:原文件名", def: "" }
|
||||
],
|
||||
body: null, upload: true },
|
||||
{ method: "GET", path: "/api/v1/documents/tasks/{task_id}", desc: "入库任务状态查询(done 附 result,failed 附 error)", auth: "none",
|
||||
query: [],
|
||||
pathParams: [{ name: "task_id", desc: "入库任务 ID", def: "" }],
|
||||
form: [], body: null, upload: false },
|
||||
{ method: "GET", path: "/api/v1/documents", desc: "文档列表(limit/offset 分页)", auth: "none",
|
||||
query: [
|
||||
{ name: "limit", desc: "每页条数(1-100)", def: "20" },
|
||||
{ name: "offset", desc: "分页游标(上一页返回的 next_offset)", def: "" }
|
||||
],
|
||||
pathParams: [], form: [], body: null, upload: false },
|
||||
{ method: "GET", path: "/api/v1/documents/{doc_id}", desc: "文档详情(L1 记录 + L2/L3 节点 + chunks 数量)", auth: "none",
|
||||
query: [],
|
||||
pathParams: [{ name: "doc_id", desc: "文档 ID", def: "" }],
|
||||
form: [], body: null, upload: false },
|
||||
{ method: "DELETE", path: "/api/v1/documents/{doc_id}", desc: "删除文档(四层集合全量删除,幂等)", auth: "bearer",
|
||||
query: [],
|
||||
pathParams: [{ name: "doc_id", desc: "文档 ID", def: "" }],
|
||||
form: [], body: null, upload: false },
|
||||
{ method: "GET", path: "/api/v1/knowledge/categories", desc: "知识分类类目集", auth: "none",
|
||||
query: [], pathParams: [], form: [], body: null, upload: false },
|
||||
{ method: "GET", path: "/api/v1/knowledge/stats", desc: "统计(四层点数 + 类目分布 + uncategorized 数)", auth: "none",
|
||||
query: [], pathParams: [], form: [], body: null, upload: false },
|
||||
{ method: "POST", path: "/api/v1/auth/login", desc: "用户名密码登录,成功签发 Bearer token(session TTL 12h)", auth: "none",
|
||||
query: [], pathParams: [], form: [],
|
||||
body: { username: "admin", password: "你的密码" }, upload: false },
|
||||
{ method: "POST", path: "/api/v1/auth/logout", desc: "退出登录(删除当前 session)", auth: "bearer",
|
||||
query: [], pathParams: [], form: [], body: null, upload: false },
|
||||
{ method: "POST", path: "/api/v1/auth/password", desc: "修改自己的密码(must_change_password 用户唯一可用接口)", auth: "bearer",
|
||||
query: [], pathParams: [], form: [],
|
||||
body: { old_password: "旧密码", new_password: "新密码至少8位" }, upload: false },
|
||||
{ method: "GET", path: "/api/v1/auth/me", desc: "当前登录用户信息(脱敏)", auth: "bearer",
|
||||
query: [], pathParams: [], form: [], body: null, upload: false },
|
||||
{ method: "GET", path: "/api/v1/auth/users", desc: "用户列表(脱敏,不含 password_hash/salt)", auth: "admin",
|
||||
query: [], pathParams: [], form: [], body: null, upload: false },
|
||||
{ method: "POST", path: "/api/v1/auth/users", desc: "创建用户(重名/弱密码 1001)", auth: "admin",
|
||||
query: [], pathParams: [], form: [],
|
||||
body: { username: "newuser", password: "初始密码至少8位", role: "user" }, upload: false },
|
||||
{ method: "POST", path: "/api/v1/auth/users/{username}/password", desc: "重置指定用户密码(成功后清除其全部 session)", auth: "admin",
|
||||
query: [],
|
||||
pathParams: [{ name: "username", desc: "目标用户名", def: "" }],
|
||||
form: [],
|
||||
body: { new_password: "新密码至少8位" }, upload: false },
|
||||
{ method: "DELETE", path: "/api/v1/auth/users/{username}", desc: "删除用户并清其 session(不能删自己/最后一个 admin)", auth: "admin",
|
||||
query: [],
|
||||
pathParams: [{ name: "username", desc: "目标用户名", def: "" }],
|
||||
form: [], body: null, upload: false }
|
||||
];
|
||||
|
||||
var API_AUTH_LABELS = { none: "免登录", bearer: "需登录", admin: "仅 admin" };
|
||||
|
||||
/* path 模板占位替换:values 为空时回退默认值,再回退 {name} 占位(供 curl 展示) */
|
||||
function substitutePath(ep, values) {
|
||||
var p = ep.path;
|
||||
ep.pathParams.forEach(function (pp) {
|
||||
var v = values[pp.name] !== undefined && values[pp.name] !== "" ? values[pp.name] : (pp.def || "{" + pp.name + "}");
|
||||
p = p.split("{" + pp.name + "}").join(v);
|
||||
});
|
||||
return p;
|
||||
}
|
||||
|
||||
function buildCurl(ep) {
|
||||
var url = location.origin + substitutePath(ep, {});
|
||||
var qs = [];
|
||||
ep.query.forEach(function (q) {
|
||||
if (q.def) { qs.push(q.name + "=" + encodeURIComponent(q.def)); }
|
||||
});
|
||||
if (qs.length) { url += "?" + qs.join("&"); }
|
||||
var parts = ['curl -X ' + ep.method + ' "' + url + '"'];
|
||||
if (ep.auth !== "none") {
|
||||
parts.push('-H "Authorization: Bearer <TOKEN>"');
|
||||
}
|
||||
if (ep.upload) {
|
||||
parts.push('-F "file=@/path/to/file.md"');
|
||||
} else if (ep.body) {
|
||||
parts.push('-H "Content-Type: application/json"');
|
||||
parts.push("-d '" + JSON.stringify(ep.body) + "'");
|
||||
}
|
||||
return parts.join(" \\\n ");
|
||||
}
|
||||
|
||||
/* 复制到剪贴板:navigator.clipboard 不可用时降级为 textarea 选中复制 */
|
||||
function copyText(text, btn) {
|
||||
function done() {
|
||||
btn.textContent = "已复制";
|
||||
setTimeout(function () { btn.textContent = "复制"; }, 1500);
|
||||
}
|
||||
function fallback() {
|
||||
var ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand("copy"); } catch (e) { /* 复制失败不阻塞 */ }
|
||||
document.body.removeChild(ta);
|
||||
done();
|
||||
}
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(done, fallback);
|
||||
} else {
|
||||
fallback();
|
||||
}
|
||||
}
|
||||
|
||||
function renderParamTable(item, title, params) {
|
||||
if (!params.length) { return; }
|
||||
item.appendChild(el("h4", title));
|
||||
var table = el("table");
|
||||
var headTr = el("tr");
|
||||
headTr.appendChild(el("th", "名称"));
|
||||
headTr.appendChild(el("th", "说明"));
|
||||
headTr.appendChild(el("th", "默认"));
|
||||
var thead = el("thead");
|
||||
thead.appendChild(headTr);
|
||||
table.appendChild(thead);
|
||||
var tbody = el("tbody");
|
||||
params.forEach(function (p) {
|
||||
var tr = el("tr");
|
||||
tr.appendChild(el("td", p.name));
|
||||
tr.appendChild(el("td", p.desc || ""));
|
||||
tr.appendChild(el("td", p.def || "(空)"));
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
table.appendChild(tbody);
|
||||
item.appendChild(table);
|
||||
}
|
||||
|
||||
/* 试一下面板内按 kind(path/query/form)渲染参数输入框 */
|
||||
function renderTryInputs(panel, kind, params) {
|
||||
params.forEach(function (p) {
|
||||
var field = el("div", null, "field");
|
||||
field.appendChild(el("label", p.name + (p.desc ? "(" + p.desc + ")" : "")));
|
||||
var input = el("input");
|
||||
input.type = "text";
|
||||
input.className = "try-" + kind + "-input";
|
||||
input.setAttribute("data-param", p.name);
|
||||
input.value = p.def || "";
|
||||
input.placeholder = p.desc || p.name;
|
||||
field.appendChild(input);
|
||||
panel.appendChild(field);
|
||||
});
|
||||
}
|
||||
|
||||
function renderTryResult(panel, status, elapsed, text) {
|
||||
var result = panel.querySelector(".try-result");
|
||||
result.querySelector(".try-status").textContent = String(status);
|
||||
result.querySelector(".try-elapsed").textContent = elapsed + " ms";
|
||||
var pretty = text;
|
||||
try { pretty = JSON.stringify(JSON.parse(text), null, 2); } catch (e) { /* 非 JSON 响应原样展示 */ }
|
||||
result.querySelector(".try-result-pre").textContent = pretty;
|
||||
result.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function sendApiTry(idx, panel) {
|
||||
var ep = API_GUIDE[idx];
|
||||
var errBox = panel.querySelector(".try-error");
|
||||
var sendBtn = panel.querySelector(".try-send");
|
||||
function fail(message) {
|
||||
errBox.textContent = message;
|
||||
errBox.classList.remove("hidden");
|
||||
}
|
||||
errBox.classList.add("hidden");
|
||||
|
||||
/* path 参数:必填,替换 path 模板占位 */
|
||||
var pathValues = {};
|
||||
var pathInputs = panel.querySelectorAll(".try-path-input");
|
||||
for (var i = 0; i < pathInputs.length; i++) {
|
||||
var pName = pathInputs[i].getAttribute("data-param");
|
||||
var pVal = pathInputs[i].value.trim();
|
||||
if (!pVal) {
|
||||
fail("请填写 path 参数:" + pName);
|
||||
return;
|
||||
}
|
||||
pathValues[pName] = encodeURIComponent(pVal);
|
||||
}
|
||||
var url = substitutePath(ep, pathValues);
|
||||
|
||||
/* query 参数:仅拼接非空值 */
|
||||
var pairs = [];
|
||||
var queryInputs = panel.querySelectorAll(".try-query-input");
|
||||
for (var j = 0; j < queryInputs.length; j++) {
|
||||
var qVal = queryInputs[j].value.trim();
|
||||
if (qVal) {
|
||||
pairs.push(queryInputs[j].getAttribute("data-param") + "=" + encodeURIComponent(qVal));
|
||||
}
|
||||
}
|
||||
if (pairs.length) { url += "?" + pairs.join("&"); }
|
||||
|
||||
var options = { method: ep.method, headers: {} };
|
||||
if (ep.upload) {
|
||||
var fileInput = panel.querySelector(".try-file");
|
||||
if (!fileInput.files || fileInput.files.length === 0) {
|
||||
fail("请选择要上传的文件");
|
||||
return;
|
||||
}
|
||||
/* FormData 由浏览器自动生成 multipart 边界,不设置 Content-Type 头 */
|
||||
var formData = new FormData();
|
||||
formData.append("file", fileInput.files[0]);
|
||||
var formInputs = panel.querySelectorAll(".try-form-input");
|
||||
for (var k = 0; k < formInputs.length; k++) {
|
||||
var fVal = formInputs[k].value.trim();
|
||||
if (fVal) { formData.append(formInputs[k].getAttribute("data-param"), fVal); }
|
||||
}
|
||||
options.body = formData;
|
||||
} else if (ep.body) {
|
||||
var raw = panel.querySelector(".try-body").value;
|
||||
try {
|
||||
JSON.parse(raw);
|
||||
} catch (e) {
|
||||
fail("Body 不是合法 JSON,未发送请求:" + (e && e.message ? e.message : e));
|
||||
return;
|
||||
}
|
||||
options.headers["Content-Type"] = "application/json";
|
||||
options.body = raw;
|
||||
}
|
||||
if (ep.auth !== "none") {
|
||||
var token = getToken();
|
||||
if (token) { options.headers["Authorization"] = "Bearer " + token; }
|
||||
}
|
||||
|
||||
sendBtn.disabled = true;
|
||||
var started = performance.now();
|
||||
fetch(url, options).then(function (resp) {
|
||||
var elapsed = Math.round(performance.now() - started);
|
||||
return resp.text().then(function (text) {
|
||||
renderTryResult(panel, resp.status, elapsed, text);
|
||||
});
|
||||
}).catch(function (err) {
|
||||
renderTryResult(panel, "请求失败", Math.round(performance.now() - started),
|
||||
String(err && err.message ? err.message : err));
|
||||
}).finally(function () {
|
||||
sendBtn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function renderApiItem(ep, idx) {
|
||||
var item = el("div", null, "api-item");
|
||||
|
||||
var head = el("div", null, "api-head");
|
||||
head.appendChild(el("span", ep.method, "method-badge method-" + ep.method.toLowerCase()));
|
||||
var code = el("code", ep.path);
|
||||
head.appendChild(code);
|
||||
head.appendChild(el("span", API_AUTH_LABELS[ep.auth] || ep.auth, "auth-badge auth-" + ep.auth));
|
||||
item.appendChild(head);
|
||||
item.appendChild(el("div", ep.desc, "api-desc"));
|
||||
|
||||
renderParamTable(item, "Path 参数", ep.pathParams);
|
||||
renderParamTable(item, "Query 参数", ep.query);
|
||||
renderParamTable(item, "表单字段", ep.form);
|
||||
|
||||
if (ep.body) {
|
||||
item.appendChild(el("h4", "Body 示例(JSON)"));
|
||||
item.appendChild(el("pre", JSON.stringify(ep.body, null, 2), "json-pre"));
|
||||
}
|
||||
|
||||
item.appendChild(el("h4", "curl 示例"));
|
||||
var curlBox = el("div", null, "curl-box");
|
||||
var curlPre = el("pre", buildCurl(ep), "curl-pre");
|
||||
var copyBtn = el("button", "复制", "action curl-copy");
|
||||
copyBtn.type = "button";
|
||||
copyBtn.addEventListener("click", function () { copyText(curlPre.textContent, copyBtn); });
|
||||
curlBox.appendChild(copyBtn);
|
||||
curlBox.appendChild(curlPre);
|
||||
item.appendChild(curlBox);
|
||||
|
||||
/* 「试一下」面板:只能从清单展开,不提供任意 URL 输入框 */
|
||||
var toggleBtn = el("button", "试一下", "action try-toggle");
|
||||
toggleBtn.type = "button";
|
||||
var panel = el("div", null, "try-panel hidden");
|
||||
toggleBtn.addEventListener("click", function () { panel.classList.toggle("hidden"); });
|
||||
item.appendChild(toggleBtn);
|
||||
|
||||
renderTryInputs(panel, "path", ep.pathParams);
|
||||
renderTryInputs(panel, "query", ep.query);
|
||||
if (ep.upload) {
|
||||
renderTryInputs(panel, "form", ep.form);
|
||||
var fileField = el("div", null, "field");
|
||||
fileField.appendChild(el("label", "文件(.txt/.md/.html/.htm/.pdf/.docx)"));
|
||||
var fileInput = el("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.className = "try-file";
|
||||
fileInput.accept = ".txt,.md,.html,.htm,.pdf,.docx";
|
||||
fileField.appendChild(fileInput);
|
||||
panel.appendChild(fileField);
|
||||
}
|
||||
if (ep.body) {
|
||||
var bodyField = el("div", null, "field");
|
||||
bodyField.appendChild(el("label", "Body(JSON,可编辑)"));
|
||||
bodyField.appendChild(el("textarea", JSON.stringify(ep.body, null, 2), "try-body"));
|
||||
panel.appendChild(bodyField);
|
||||
}
|
||||
|
||||
var sendBtn = el("button", "发送", "primary try-send");
|
||||
sendBtn.type = "button";
|
||||
sendBtn.addEventListener("click", function () { sendApiTry(idx, panel); });
|
||||
panel.appendChild(sendBtn);
|
||||
panel.appendChild(el("div", null, "error-bar hidden try-error"));
|
||||
|
||||
var result = el("div", null, "result-box hidden try-result");
|
||||
var statusKv = el("div", null, "kv");
|
||||
statusKv.appendChild(el("span", "状态码", "k"));
|
||||
statusKv.appendChild(el("span", null, "try-status"));
|
||||
result.appendChild(statusKv);
|
||||
var elapsedKv = el("div", null, "kv");
|
||||
elapsedKv.appendChild(el("span", "耗时", "k"));
|
||||
elapsedKv.appendChild(el("span", null, "try-elapsed"));
|
||||
result.appendChild(elapsedKv);
|
||||
result.appendChild(el("pre", null, "try-result-pre"));
|
||||
panel.appendChild(result);
|
||||
|
||||
item.appendChild(panel);
|
||||
return item;
|
||||
}
|
||||
|
||||
function renderApiGuide() {
|
||||
var container = document.getElementById("api-guide-list");
|
||||
clearChildren(container);
|
||||
API_GUIDE.forEach(function (ep, idx) {
|
||||
container.appendChild(renderApiItem(ep, idx));
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- 初始化 ---------- */
|
||||
|
||||
(function init() {
|
||||
@@ -877,9 +1538,16 @@ function loadCategories() {
|
||||
showLogin();
|
||||
return;
|
||||
}
|
||||
/* 本地有 token 时先调 /auth/me 验证登录态 */
|
||||
api("/api/v1/auth/me").then(function (user) {
|
||||
afterLogin(user);
|
||||
}).catch(function () {
|
||||
saveAuth(getToken(), user);
|
||||
afterAuth(user);
|
||||
}).catch(function (err) {
|
||||
if (err && err.code === 1006) {
|
||||
/* must_change_password 用户被 /me 拦截:强制先改密(后端放行 /auth/password) */
|
||||
openPasswordModal(true);
|
||||
return;
|
||||
}
|
||||
showLogin();
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user