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:
2026-07-31 21:29:02 +08:00
parent 2ab8b56a01
commit 92b062c048
24 changed files with 2771 additions and 350 deletions
+691 -23
View File
@@ -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/.docx202 异步)", 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 附 resultfailed 附 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 tokensession 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);
}
/* 试一下面板内按 kindpath/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", "BodyJSON,可编辑)"));
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();
});
})();