Files
QMDSearch/app/static/admin.html
T
kplam e2e8e6829d feat: add document file download and admin page file display
1. 新增将文档元数据存入L1向量库的功能
2. 新增文档文件下载API接口,支持路径安全校验
3. 后端文档详情接口新增文件信息返回字段
4. 管理后台页面新增原始文件信息展示与下载链接
2026-07-31 22:46:13 +08:00

1580 lines
60 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>知识库管理后台</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
background: #f5f6f8;
color: #24292f;
}
header {
background: #1f2937;
color: #fff;
padding: 12px 24px;
}
header h1 { font-size: 18px; margin: 0 0 10px; }
nav button {
background: transparent;
border: 1px solid #4b5563;
color: #d1d5db;
padding: 6px 14px;
margin-right: 8px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
nav button.active { background: #3b82f6; border-color: #3b82f6; color: #fff; }
main { padding: 20px 24px; max-width: 1080px; margin: 0 auto; }
section { background: #fff; border: 1px solid #e5e7eb; border-radius: 6px; padding: 16px 20px; }
section h2 { font-size: 16px; margin: 0 0 14px; border-bottom: 1px solid #e5e7eb; padding-bottom: 8px; }
.hidden { display: none; }
.error-bar {
background: #fef2f2;
border: 1px solid #fca5a5;
color: #b91c1c;
border-radius: 4px;
padding: 8px 12px;
margin-bottom: 12px;
font-size: 13px;
white-space: pre-wrap;
}
.cards { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 16px; }
.card {
flex: 1 1 140px;
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 12px;
background: #fafafa;
}
.card .num { font-size: 24px; font-weight: 600; }
.card .label { font-size: 12px; color: #6b7280; margin-top: 4px; }
.bar-row { display: flex; align-items: center; margin-bottom: 6px; font-size: 13px; }
.bar-row .name { width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bar-row .bar-track { flex: 1; background: #f3f4f6; border-radius: 3px; height: 16px; margin: 0 8px; }
.bar-row .bar-fill { background: #3b82f6; height: 100%; border-radius: 3px; min-width: 2px; }
.bar-row .count { width: 48px; text-align: right; color: #374151; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { border-bottom: 1px solid #e5e7eb; padding: 8px; text-align: left; vertical-align: top; }
th { background: #f9fafb; color: #374151; }
button.action {
border: 1px solid #d1d5db;
background: #fff;
border-radius: 4px;
padding: 4px 10px;
margin-right: 6px;
cursor: pointer;
font-size: 12px;
}
button.action:hover { background: #f3f4f6; }
button.danger { color: #b91c1c; border-color: #fca5a5; }
button.primary {
background: #3b82f6; color: #fff; border: none;
border-radius: 4px; padding: 8px 18px; cursor: pointer; font-size: 14px;
}
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"], input[type="password"], textarea, select {
width: 100%; border: 1px solid #d1d5db; border-radius: 4px;
padding: 8px; font-size: 13px; font-family: inherit;
}
textarea { min-height: 160px; resize: vertical; }
.result-box {
margin-top: 14px; border: 1px solid #e5e7eb; border-radius: 6px;
background: #fafafa; padding: 12px; font-size: 13px;
}
.result-box .kv { margin-bottom: 6px; }
.result-box .k { color: #6b7280; display: inline-block; min-width: 110px; }
.detail-panel {
margin-top: 14px; border: 1px solid #d1d5db; border-radius: 6px; padding: 12px;
}
.detail-panel h3 { margin: 0 0 8px; font-size: 14px; }
.detail-panel 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;
}
.node-item { border-left: 3px solid #93c5fd; padding: 4px 8px; margin-bottom: 6px; background: #f8fafc; }
.node-item .path { font-size: 12px; color: #6b7280; }
.hit-item { border: 1px solid #e5e7eb; border-radius: 6px; padding: 10px; margin-bottom: 10px; }
.hit-item .meta { font-size: 12px; color: #6b7280; margin-bottom: 6px; }
.hit-item .snippet { font-size: 13px; white-space: pre-wrap; word-break: break-word; }
.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; }
.muted { color: #6b7280; font-size: 12px; }
.status-badge {
display: inline-block; border-radius: 3px; padding: 1px 8px;
font-size: 12px; margin-left: 6px; border: 1px solid transparent;
}
.status-running { background: #eff6ff; color: #1d4ed8; border-color: #93c5fd; }
.status-done { background: #f0fdf4; color: #15803d; border-color: #86efac; }
.status-failed { background: #fef2f2; color: #b91c1c; border-color: #fca5a5; }
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;
}
.login-box {
background: #fff; border-radius: 8px; padding: 24px 28px; width: 320px;
box-shadow: 0 6px 20px rgba(0,0,0,0.25);
}
.login-box h2 { font-size: 16px; margin: 0 0 16px; }
.extracted-info {
background: #f0f9ff; border: 1px solid #bae6fd; border-radius: 6px;
padding: 10px 12px; margin-bottom: 12px; font-size: 13px;
}
.extracted-info .ei-title { font-weight: 600; margin-bottom: 6px; color: #0369a1; }
.extracted-info .ei-row { margin-bottom: 3px; }
.extracted-info .ei-k { color: #6b7280; margin-right: 4px; }
.summary-box {
background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 6px;
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>
<div id="login-overlay" class="login-overlay">
<div class="login-box">
<h2>登录知识库后台</h2>
<div class="error-bar hidden" id="error-login"></div>
<form id="login-form">
<div class="field">
<label for="login-username">用户名</label>
<input type="text" id="login-username" name="username" required autocomplete="username">
</div>
<div class="field">
<label for="login-password">密码</label>
<input type="password" id="login-password" name="password" required autocomplete="current-password">
</div>
<button type="submit" class="primary">登录</button>
</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 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>
<button type="button" data-target="section-docs">文档管理</button>
<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>
<section id="section-overview">
<h2>概览</h2>
<div class="error-bar hidden" id="error-overview"></div>
<div class="toolbar">
<button type="button" class="action" id="btn-refresh-overview">刷新</button>
</div>
<div class="cards" id="overview-cards"></div>
<h3 style="font-size:14px;">类目分布</h3>
<div id="overview-categories"></div>
</section>
<section id="section-docs" class="hidden">
<h2>文档管理</h2>
<div class="error-bar hidden" id="error-docs"></div>
<table>
<thead>
<tr><th>标题</th><th>类目</th><th>标签</th><th>L1 摘要</th><th>操作</th></tr>
</thead>
<tbody id="docs-tbody"></tbody>
</table>
<div class="toolbar">
<button type="button" class="action" id="btn-load-more">加载更多</button>
<span class="muted" id="docs-status"></span>
</div>
<div class="detail-panel hidden" id="doc-detail"></div>
</section>
<section id="section-ingest" class="hidden">
<h2>文档入库</h2>
<div class="error-bar hidden" id="error-ingest"></div>
<form id="ingest-form">
<div class="field">
<label for="ingest-title">标题</label>
<input type="text" id="ingest-title" name="title" required>
</div>
<div class="field">
<label for="ingest-source">来源</label>
<input type="text" id="ingest-source" name="source" placeholder="例如:manual / web / file">
</div>
<div class="field">
<label for="ingest-text">正文</label>
<textarea id="ingest-text" name="text" required></textarea>
</div>
<button type="submit" class="primary" id="btn-ingest-submit">提交入库</button>
</form>
<h3 style="font-size:14px; margin-top:24px;">或上传文件</h3>
<form id="upload-form">
<div class="field">
<label for="upload-file">选择文件(支持 .txt/.md/.html/.htm/.pdf/.docx</label>
<input type="file" id="upload-file" name="file" accept=".txt,.md,.html,.htm,.pdf,.docx" required>
</div>
<div class="field">
<label for="upload-title">标题(可选,默认取文件名)</label>
<input type="text" id="upload-title" name="title" placeholder="留空则使用文件名去扩展">
</div>
<div class="field">
<label for="upload-source">来源(可选,默认 file:原文件名)</label>
<input type="text" id="upload-source" name="source" placeholder="例如:manual / web">
</div>
<button type="submit" class="primary" id="btn-upload-submit">上传入库</button>
</form>
<div class="result-box hidden" id="ingest-result"></div>
</section>
<section id="section-search" class="hidden">
<h2>检索测试台</h2>
<div class="error-bar hidden" id="error-search"></div>
<form id="search-form">
<div class="field">
<label for="search-query">查询语句</label>
<input type="text" id="search-query" name="query" required>
</div>
<div class="field">
<label for="search-topk">top_k</label>
<input type="number" id="search-topk" name="top_k" value="5" min="1" max="50">
</div>
<div class="field">
<label><input type="checkbox" id="search-summarize" name="summarize"> 对结果生成 AI 总结</label>
</div>
<button type="submit" class="primary" id="btn-search-submit">检索</button>
</form>
<div class="result-box hidden" id="search-result"></div>
</section>
<section id="section-categories" class="hidden">
<h2>类目列表</h2>
<div class="error-bar hidden" id="error-categories"></div>
<table>
<thead>
<tr><th>名称</th><th>描述</th></tr>
</thead>
<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";
/* ---------- 通用工具 ---------- */
function el(tag, text, className) {
var node = document.createElement(tag);
if (className) { node.className = className; }
if (text !== undefined && text !== null) { node.textContent = String(text); }
return node;
}
function clearChildren(node) {
while (node.firstChild) { node.removeChild(node.firstChild); }
}
function formatFileSize(bytes) {
var n = Number(bytes);
if (!n || n <= 0) { return "0 B"; }
var units = ["B", "KB", "MB", "GB"];
var i = 0;
while (n >= 1024 && i < units.length - 1) { n = n / 1024; i++; }
return (i === 0 ? n : n.toFixed(1)) + " " + units[i];
}
function showError(boxId, err) {
var box = document.getElementById(boxId);
clearChildren(box);
var code = (err && err.code !== undefined) ? err.code : "网络错误";
var message = (err && err.message) ? err.message : String(err);
box.textContent = "错误 [" + code + "] " + message;
box.classList.remove("hidden");
}
function hideError(boxId) {
document.getElementById(boxId).classList.add("hidden");
}
/* ---------- 认证状态管理 ---------- */
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();
}
/* 渲染主界面:顶栏用户区 + 角色门禁(仅 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;
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");
var payload = {
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" },
body: JSON.stringify(payload)
}).then(function (resp) { return resp.json(); }).then(function (body) {
if (body.code !== 0) {
showError("error-login", { code: body.code, message: body.message });
return;
}
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 () {
api("/api/v1/auth/logout", { method: "POST" }).catch(function () {
/* 服务端退出失败不阻塞本地登出 */
}).finally(function () {
clearAuth();
showLogin();
});
});
/* ---------- 修改密码 ---------- */
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 || {});
var token = getToken();
if (token && !options.headers["Authorization"]) {
options.headers["Authorization"] = "Bearer " + token;
}
return fetch(path, options).then(function (resp) {
return resp.json().catch(function () {
throw { code: "HTTP " + resp.status, message: "响应解析失败" };
});
}).then(function (body) {
if (body.code !== 0) {
if (body.code === 1005) {
clearAuth();
showLogin();
}
throw { code: body.code, message: body.message };
}
return body.data;
}).catch(function (err) {
if (err && err.code !== undefined) { throw err; }
throw { code: "NETWORK", message: "网络请求失败: " + (err && err.message ? err.message : err) };
});
}
function truncate(text, maxLen) {
if (!text) { return ""; }
var s = String(text);
return s.length > maxLen ? s.slice(0, maxLen) + "…" : s;
}
/* ---------- 导航切换 ---------- */
var loadedOnce = {};
function activateSection(targetId) {
var sections = document.querySelectorAll("main section");
sections.forEach(function (sec) {
sec.classList.toggle("hidden", sec.id !== targetId);
});
var buttons = document.querySelectorAll("#nav button");
buttons.forEach(function (btn) {
btn.classList.toggle("active", btn.getAttribute("data-target") === targetId);
});
if (!loadedOnce[targetId]) {
loadedOnce[targetId] = true;
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(); }
}
}
document.getElementById("nav").addEventListener("click", function (event) {
var btn = event.target.closest("button[data-target]");
if (btn) { activateSection(btn.getAttribute("data-target")); }
});
/* ---------- 1. 概览 ---------- */
function loadOverview() {
hideError("error-overview");
api("/api/v1/knowledge/stats").then(function (data) {
renderOverviewCards(data);
renderOverviewCategories(data.categories || {});
}).catch(function (err) { showError("error-overview", err); });
}
function renderOverviewCards(data) {
var container = document.getElementById("overview-cards");
clearChildren(container);
var collections = data.collections || {};
var cards = [
["L1 文档总结", collections.doc_l1],
["L2 大纲节点", collections.doc_l2],
["L3 内容大纲", collections.doc_l3],
["Chunks", collections.chunks],
["文档总数", data.documents_total],
["未分类文档", data.uncategorized_count]
];
cards.forEach(function (pair) {
var card = el("div", null, "card");
card.appendChild(el("div", pair[1] === undefined ? 0 : pair[1], "num"));
card.appendChild(el("div", pair[0], "label"));
container.appendChild(card);
});
}
function renderOverviewCategories(categories) {
var container = document.getElementById("overview-categories");
clearChildren(container);
var names = Object.keys(categories);
if (names.length === 0) {
container.appendChild(el("div", "暂无数据", "muted"));
return;
}
var max = 1;
names.forEach(function (name) { max = Math.max(max, categories[name]); });
names.sort(function (a, b) { return categories[b] - categories[a]; });
names.forEach(function (name) {
var count = categories[name];
var row = el("div", null, "bar-row");
row.appendChild(el("span", name, "name"));
var track = el("div", null, "bar-track");
var fill = el("div", null, "bar-fill");
fill.style.width = Math.round((count / max) * 100) + "%";
track.appendChild(fill);
row.appendChild(track);
row.appendChild(el("span", count, "count"));
container.appendChild(row);
});
}
document.getElementById("btn-refresh-overview").addEventListener("click", loadOverview);
/* ---------- 2. 文档管理 ---------- */
var docsOffset = null;
function loadDocuments(reset) {
hideError("error-docs");
if (reset) {
docsOffset = null;
clearChildren(document.getElementById("docs-tbody"));
}
var path = "/api/v1/documents?limit=20";
if (docsOffset !== null) { path += "&offset=" + encodeURIComponent(docsOffset); }
api(path).then(function (data) {
renderDocuments(data.items || []);
docsOffset = data.next_offset;
document.getElementById("btn-load-more").disabled = docsOffset === null;
document.getElementById("docs-status").textContent =
docsOffset === null ? "已加载全部" : "还有更多";
}).catch(function (err) { showError("error-docs", err); });
}
function renderDocuments(items) {
var tbody = document.getElementById("docs-tbody");
items.forEach(function (item) {
var tr = el("tr");
tr.appendChild(el("td", item.title || "(无标题)"));
tr.appendChild(el("td", item.category || ""));
var tagsTd = el("td");
(item.tags || []).forEach(function (tag) { tagsTd.appendChild(el("span", tag, "tag")); });
tr.appendChild(tagsTd);
tr.appendChild(el("td", truncate(item.summary, 80)));
var opsTd = el("td");
var detailBtn = el("button", "详情", "action");
detailBtn.type = "button";
detailBtn.addEventListener("click", function () { loadDocDetail(item.doc_id); });
var deleteBtn = el("button", "删除", "action danger");
deleteBtn.type = "button";
deleteBtn.addEventListener("click", function () { deleteDocument(item.doc_id, item.title); });
opsTd.appendChild(detailBtn);
opsTd.appendChild(deleteBtn);
tr.appendChild(opsTd);
tbody.appendChild(tr);
});
}
document.getElementById("btn-load-more").addEventListener("click", function () {
loadDocuments(false);
});
function loadDocDetail(docId) {
hideError("error-docs");
api("/api/v1/documents/" + encodeURIComponent(docId)).then(function (data) {
renderDocDetail(data);
}).catch(function (err) { showError("error-docs", err); });
}
function renderDocDetail(data) {
var panel = document.getElementById("doc-detail");
clearChildren(panel);
panel.classList.remove("hidden");
var l1 = data.l1 || {};
panel.appendChild(el("h3", "文档详情:" + (l1.title || data.l1 && l1.doc_id || "")));
var meta = el("div", null, "kv");
meta.appendChild(el("span", "doc_id", "k"));
meta.appendChild(el("span", l1.doc_id || ""));
panel.appendChild(meta);
var meta2 = el("div", null, "kv");
meta2.appendChild(el("span", "类目 / 标签", "k"));
meta2.appendChild(el("span", (l1.category || "") + " / " + (l1.tags || []).join(", ")));
panel.appendChild(meta2);
var meta3 = el("div", null, "kv");
meta3.appendChild(el("span", "chunks_count", "k"));
meta3.appendChild(el("span", data.chunks_count));
panel.appendChild(meta3);
/* 原始文件:有关联文件时展示文件名 + 大小 + 下载链接(textContent 防 XSS */
if (data.file) {
var fileRow = el("div", null, "kv");
fileRow.appendChild(el("span", "原始文件", "k"));
fileRow.appendChild(
el("span", (data.file.filename || "") + "" + formatFileSize(data.file.size_bytes) + "")
);
var dlLink = el("a", "下载");
dlLink.href = location.origin + (data.file.url || "");
dlLink.setAttribute("download", data.file.filename || "");
fileRow.appendChild(dlLink);
panel.appendChild(fileRow);
}
panel.appendChild(el("h3", "L1 全文"));
panel.appendChild(el("pre", l1.text || ""));
panel.appendChild(el("h3", "L2 节点(" + (data.l2_nodes || []).length + ""));
renderNodes(panel, data.l2_nodes || []);
panel.appendChild(el("h3", "L3 节点(" + (data.l3_nodes || []).length + ""));
renderNodes(panel, data.l3_nodes || []);
}
function renderNodes(panel, nodes) {
if (nodes.length === 0) {
panel.appendChild(el("div", "无", "muted"));
return;
}
nodes.forEach(function (node) {
var item = el("div", null, "node-item");
item.appendChild(el("div", node.section_path || "", "path"));
item.appendChild(el("div", node.text || ""));
panel.appendChild(item);
});
}
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); });
}
/* ---------- 3. 文档入库(异步任务轮询) ---------- */
var INGEST_STATUS_TEXT = {
pending: "排队中",
summarizing: "总结中",
classifying: "分类中",
embedding: "向量化中",
writing: "写入中",
done: "完成",
failed: "失败"
};
var INGEST_POLL_INTERVAL_MS = 2000;
var INGEST_POLL_MAX_ATTEMPTS = 150; /* 150 次 × 2s = 5 分钟超时 */
var ingestPollTimer = null;
function stopIngestPolling() {
if (ingestPollTimer !== null) {
clearInterval(ingestPollTimer);
ingestPollTimer = null;
}
}
function restoreIngestButton() {
var submitBtn = document.getElementById("btn-ingest-submit");
submitBtn.disabled = false;
submitBtn.textContent = "提交入库";
}
function makeStatusBadge(status) {
var cls = "status-running";
if (status === "done") { cls = "status-done"; }
if (status === "failed") { cls = "status-failed"; }
var badge = el("span", INGEST_STATUS_TEXT[status] || status, "status-badge " + cls);
badge.id = "ingest-status-badge";
return badge;
}
function updateIngestStatus(status) {
var old = document.getElementById("ingest-status-badge");
if (old && old.parentNode) {
old.parentNode.replaceChild(makeStatusBadge(status), old);
}
}
function ingestKv(box, label, value) {
var row = el("div", null, "kv");
row.appendChild(el("span", label, "k"));
row.appendChild(el("span", value));
box.appendChild(row);
}
function renderIngestHeader(taskId, status) {
var box = document.getElementById("ingest-result");
clearChildren(box);
box.classList.remove("hidden");
var row = el("div", null, "kv");
row.appendChild(el("span", "任务已提交:" + taskId));
row.appendChild(makeStatusBadge(status));
box.appendChild(row);
return box;
}
function renderIngestDone(task) {
var box = renderIngestHeader(task.task_id, "done");
var result = task.result || {};
var summary = result.summary || {};
ingestKv(box, "document_id", result.document_id);
ingestKv(box, "类目", (result.category || "") + "(置信度 " + result.category_confidence + "");
ingestKv(box, "标签", (result.tags || []).join(", "));
ingestKv(box, "总结层级 level", summary.level);
ingestKv(box, "写入集合", result.collection);
ingestKv(box, "chunks_count", result.chunks_count);
box.appendChild(el("h3", "L1 总结"));
box.appendChild(el("pre", summary.l1_summary || ""));
}
function renderIngestFailed(task) {
var box = renderIngestHeader(task.task_id, "failed");
var error = task.error || {};
ingestKv(box, "失败阶段", error.stage || "");
ingestKv(box, "错误信息", error.message || "");
var partial = error.partial_summary;
if (partial && partial.l1_summary) {
box.appendChild(el("div", "已产出总结保留:任务失败前已生成 L1 摘要", "fallback-flag"));
box.appendChild(el("h3", "L1 总结"));
box.appendChild(el("pre", partial.l1_summary));
}
}
function renderIngestTimeout(taskId) {
var box = document.getElementById("ingest-result");
box.appendChild(el("div", "任务仍在进行,可稍后凭 task_id 查询:" + taskId, "fallback-flag"));
}
function startIngestPolling(taskId) {
var attempts = 0;
document.getElementById("btn-ingest-submit").textContent = "入库中…";
ingestPollTimer = setInterval(function () {
attempts += 1;
if (attempts > INGEST_POLL_MAX_ATTEMPTS) {
stopIngestPolling();
renderIngestTimeout(taskId);
restoreIngestButton();
return;
}
api("/api/v1/documents/tasks/" + encodeURIComponent(taskId)).then(function (task) {
updateIngestStatus(task.status);
if (task.status === "done") {
stopIngestPolling();
renderIngestDone(task);
restoreIngestButton();
} else if (task.status === "failed") {
stopIngestPolling();
renderIngestFailed(task);
restoreIngestButton();
}
}).catch(function (err) {
stopIngestPolling();
showError("error-ingest", err);
restoreIngestButton();
});
}, INGEST_POLL_INTERVAL_MS);
}
document.getElementById("ingest-form").addEventListener("submit", function (event) {
event.preventDefault();
hideError("error-ingest");
stopIngestPolling();
var submitBtn = document.getElementById("btn-ingest-submit");
submitBtn.disabled = true;
submitBtn.textContent = "提交中…";
var payload = {
title: document.getElementById("ingest-title").value,
source: document.getElementById("ingest-source").value,
text: document.getElementById("ingest-text").value
};
api("/api/v1/documents", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
}).then(function (data) {
renderIngestHeader(data.task_id, data.status || "pending");
startIngestPolling(data.task_id);
}).catch(function (err) {
showError("error-ingest", err);
restoreIngestButton();
});
});
document.getElementById("upload-form").addEventListener("submit", function (event) {
event.preventDefault();
hideError("error-ingest");
stopIngestPolling();
var fileInput = document.getElementById("upload-file");
if (!fileInput.files || fileInput.files.length === 0) {
showError("error-ingest", { code: "VALIDATION", message: "请选择文件" });
return;
}
var submitBtn = document.getElementById("btn-upload-submit");
submitBtn.disabled = true;
submitBtn.textContent = "上传中…";
var formData = new FormData();
formData.append("file", fileInput.files[0]);
var title = document.getElementById("upload-title").value;
var source = document.getElementById("upload-source").value;
if (title) { formData.append("title", title); }
if (source) { formData.append("source", source); }
api("/api/v1/documents/upload", {
method: "POST",
body: formData
}).then(function (data) {
renderIngestHeader(data.task_id, data.status || "pending");
startIngestPolling(data.task_id);
}).catch(function (err) {
showError("error-ingest", err);
}).finally(function () {
submitBtn.disabled = false;
submitBtn.textContent = "上传入库";
});
});
/* ---------- 4. 检索测试台 ---------- */
document.getElementById("search-form").addEventListener("submit", function (event) {
event.preventDefault();
hideError("error-search");
var submitBtn = document.getElementById("btn-search-submit");
submitBtn.disabled = true;
var payload = {
query: document.getElementById("search-query").value,
top_k: parseInt(document.getElementById("search-topk").value, 10) || 5,
summarize: document.getElementById("search-summarize").checked
};
api("/api/v1/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
}).then(function (data) {
renderSearchResult(data);
}).catch(function (err) {
showError("error-search", err);
}).finally(function () {
submitBtn.disabled = false;
});
});
function eiRow(k, v) {
var row = el("div", null, "ei-row");
row.appendChild(el("span", k + ":", "ei-k"));
row.appendChild(el("span", v || "(无)"));
return row;
}
function renderSearchResult(data) {
var box = document.getElementById("search-result");
clearChildren(box);
box.classList.remove("hidden");
var routed = el("div", null, "kv");
routed.appendChild(el("span", "routed_categories", "k"));
routed.appendChild(el("span", (data.routed_categories || []).join(", ") || "(无)"));
box.appendChild(routed);
if (data.fallback) {
box.appendChild(el("div", "fallback:本次检索触发了回退策略(路由类目无命中,已降级全局检索)", "fallback-flag"));
}
if (data.summary) {
var sumBox = el("div", null, "summary-box");
sumBox.appendChild(el("div", "AI 总结", "sum-title"));
sumBox.appendChild(el("div", data.summary));
box.appendChild(sumBox);
}
var ei = data.extracted_info;
if (ei) {
var eiBox = el("div", null, "extracted-info");
eiBox.appendChild(el("div", "AI 提取的关键信息", "ei-title"));
eiBox.appendChild(eiRow("改写", ei.rewrite));
eiBox.appendChild(eiRow("关键词", (ei.keywords || []).join(", ")));
eiBox.appendChild(eiRow("实体", (ei.entities || []).join(", ")));
eiBox.appendChild(eiRow("意图", ei.intent));
eiBox.appendChild(eiRow("时间范围", ei.time_range));
eiBox.appendChild(eiRow("命中类目", (ei.categories || []).join(", ")));
box.appendChild(eiBox);
}
var hits = data.hits || [];
box.appendChild(el("div", "命中 " + hits.length + " 条", "muted"));
hits.forEach(function (hit) {
var item = el("div", null, "hit-item");
var meta = el("div", null, "meta");
meta.textContent = "score=" + hit.score + " | doc_id=" + hit.doc_id +
" | 标题=" + (hit.title || "") + " | section=" + (hit.section_path || "");
item.appendChild(meta);
item.appendChild(el("div", hit.text || "", "snippet"));
if (hit.doc_summary) {
var summaryRow = el("div", null, "meta");
summaryRow.textContent = "文档摘要:" + hit.doc_summary;
item.appendChild(summaryRow);
}
box.appendChild(item);
});
}
/* ---------- 5. 类目列表 ---------- */
function loadCategories() {
hideError("error-categories");
api("/api/v1/knowledge/categories").then(function (data) {
var tbody = document.getElementById("categories-tbody");
clearChildren(tbody);
(data.categories || []).forEach(function (cat) {
var tr = el("tr");
tr.appendChild(el("td", cat.name));
tr.appendChild(el("td", cat.description || ""));
tbody.appendChild(tr);
});
}).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() {
if (!getToken()) {
showLogin();
return;
}
/* 本地有 token 时先调 /auth/me 验证登录态 */
api("/api/v1/auth/me").then(function (user) {
saveAuth(getToken(), user);
afterAuth(user);
}).catch(function (err) {
if (err && err.code === 1006) {
/* must_change_password 用户被 /me 拦截:强制先改密(后端放行 /auth/password */
openPasswordModal(true);
return;
}
showLogin();
});
})();
</script>
</body>
</html>