Files
kplam bdd30f0a88 fix: 修复会话鉴权「登录后 token 无效」并补齐 NAS 部署流程
- deps.py: _create_redis_client 增加同步 ping 校验,Redis 不可达时正确降级为内存模式
- main.py: lifespan 复用 deps 的 UserStore 单例,避免 admin 与 API 请求实例不一致
- 前端: 强制改密弹窗(must_change_password 用户)、兼容新旧登录返回格式、markPasswordChanged
- 新增 NAS SSH 部署脚本与批处理测试;gitignore 前端构建产物
2026-08-04 11:38:28 +08:00

2311 lines
89 KiB
HTML
Raw Permalink 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; }
.toolbar.users-toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.users-toolbar input[type="text"], .users-toolbar select { width: auto; }
.users-toolbar input[type="text"] { min-width: 200px; }
.user-role-select { width: auto; display: inline-block; margin-right: 4px; }
.muted { color: #6b7280; font-size: 12px; }
.status-badge {
display: inline-block; border-radius: 3px; padding: 1px 8px;
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; }
/* 通用 overlay 弹窗组件:固定全屏遮罩 + 居中卡片 */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.45);
display: flex; align-items: center; justify-content: center; z-index: 200;
}
.modal-card {
background: #fff; border-radius: 8px; padding: 20px 24px; width: 380px;
max-width: calc(100vw - 32px); box-shadow: 0 6px 20px rgba(0,0,0,0.25);
}
.modal-card .modal-title { font-size: 16px; margin: 0 0 14px; }
.modal-card .modal-content { font-size: 13px; }
.modal-card .modal-actions { margin-top: 14px; display: flex; gap: 8px; justify-content: flex-end; }
.modal-card .modal-strength-hint { margin-top: -6px; margin-bottom: 12px; font-size: 12px; }
.modal-card .modal-confirm-text { margin-bottom: 10px; }
.extracted-info {
background: #f0f9ff; border: 1px solid #bae6fd; border-radius: 6px;
padding: 10px 12px; margin-bottom: 12px; font-size: 13px;
}
.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>
<!-- 通用弹窗挂载点:openModal 动态注入 .modal-overlay/.modal-card -->
<div id="modal-root"></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-progress" id="nav-progress">入库进度</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>
<button type="button" data-target="section-profile" id="nav-profile">个人中心</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-profile" class="hidden">
<h2>个人中心</h2>
<div class="error-bar hidden" id="error-profile"></div>
<h3 style="font-size:14px;">账号信息</h3>
<div class="cards" id="profile-cards"></div>
<h3 style="font-size:14px; margin-top:24px;">修改密码</h3>
<form id="profile-password-form">
<div class="field">
<label for="profile-old-password">旧密码</label>
<input type="password" id="profile-old-password" name="old_password" required autocomplete="current-password">
</div>
<div class="field">
<label for="profile-new-password">新密码(至少 8 位)</label>
<input type="password" id="profile-new-password" name="new_password" required autocomplete="new-password">
</div>
<div class="field">
<label for="profile-confirm-password">确认新密码</label>
<input type="password" id="profile-confirm-password" name="confirm_password" required autocomplete="new-password">
</div>
<div class="muted" id="profile-strength-hint"></div>
<button type="submit" class="primary" id="btn-profile-password-submit">确认修改</button>
</form>
<h3 style="font-size:14px; margin-top:24px;">会话</h3>
<button type="button" class="action danger" id="btn-profile-logout">退出登录</button>
</section>
<section id="section-docs" class="hidden">
<h2>文档管理</h2>
<div class="error-bar hidden" id="error-docs"></div>
<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" multiple 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-progress" class="hidden">
<h2>入库进度</h2>
<div class="error-bar hidden" id="error-progress"></div>
<div class="toolbar">
<button type="button" class="action" id="btn-refresh-progress">刷新</button>
<span class="muted" id="progress-stats"></span>
</div>
<table>
<thead>
<tr><th>文件名</th><th>状态</th><th>创建时间</th><th>更新时间</th><th>操作</th></tr>
</thead>
<tbody id="progress-tbody"></tbody>
</table>
</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 users-toolbar">
<input type="text" id="user-search" placeholder="按用户名搜索" autocomplete="off">
<select id="user-role-filter">
<option value="all" selected>全部角色</option>
<option value="admin">admin</option>
<option value="user">user</option>
</select>
<button type="button" class="action" id="btn-refresh-users">刷新</button>
<span class="muted" id="users-status"></span>
</div>
<table>
<thead>
<tr><th>用户名</th><th>角色</th><th>须改密</th><th>创建时间</th><th>启用</th><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 = {};
stopProgressPolling();
unmountUsersSection();
}
/* 渲染主界面:顶栏用户区 + 角色门禁(仅 admin 挂载用户管理区块)
admin 默认进概览,user 角色默认进个人中心(user 无需访问概览统计) */
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();
activateSection("section-overview");
} else {
unmountUsersSection();
activateSection("section-profile");
}
}
/* 登录/会话校验成功后的统一入口: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) });
});
});
/* 退出登录:调用后端 logout 端点,失败不阻塞本地登出(顶栏 + 个人中心共用) */
function doLogout() {
api("/api/v1/auth/logout", { method: "POST" }).catch(function () {
/* 服务端退出失败不阻塞本地登出 */
}).finally(function () {
clearAuth();
showLogin();
});
}
document.getElementById("btn-logout").addEventListener("click", doLogout);
/* ---------- 修改密码 ---------- */
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);
});
});
/* ---------- 个人中心 ---------- */
/* 进入区块时加载当前账号信息:GET /auth/me 返回 username/role/must_change_password/enabled/created_at */
function loadProfile() {
hideError("error-profile");
api("/api/v1/auth/me").then(function (user) {
renderProfile(user);
}).catch(function (err) { showError("error-profile", err); });
}
/* 账号信息卡片:用户名 / 角色徽章 / 创建时间 / 须改密 / 启用状态 */
function profileCard(label, value, valueClassName) {
var card = el("div", null, "card");
var num = el("div", null, "num");
if (valueClassName) {
var span = el("span", value, valueClassName);
num.appendChild(span);
} else {
num.textContent = value;
}
card.appendChild(num);
card.appendChild(el("div", label, "label"));
return card;
}
function renderProfile(user) {
var container = document.getElementById("profile-cards");
clearChildren(container);
container.appendChild(profileCard("用户名", user.username || ""));
var roleBadgeClass = "role-badge" + (user.role === "admin" ? " role-admin" : "");
container.appendChild(profileCard("角色", user.role || "", roleBadgeClass));
container.appendChild(profileCard("创建时间", user.created_at || ""));
container.appendChild(profileCard("须改密", user.must_change_password ? "是" : "否"));
container.appendChild(profileCard("启用状态", user.enabled ? "启用" : "禁用"));
}
/* 强度提示:复用 Task 3 文案(<8 弱 / ≥8 中 / ≥12 强,纯文案) */
function updateProfileStrength() {
var pwd = document.getElementById("profile-new-password").value || "";
var level = "";
if (pwd.length > 0) {
if (pwd.length < 8) { level = "弱"; }
else if (pwd.length >= 12) { level = "强"; }
else { level = "中"; }
}
document.getElementById("profile-strength-hint").textContent = level ? "密码强度:" + level : "";
}
document.getElementById("profile-new-password").addEventListener("input", updateProfileStrength);
/* 个人中心改密表单:与顶栏改密弹窗同一端点 POST /auth/password
- 两次不一致本地拦截
- 旧密码错误 1005、新密码 <8 位 1001 由后端返回,统一进错误条
- 成功后:更新本地 user 状态(清除 must_change_password)、清空表单、刷新账号信息、成功提示 */
document.getElementById("profile-password-form").addEventListener("submit", function (event) {
event.preventDefault();
hideError("error-profile");
var newPwd = document.getElementById("profile-new-password").value;
if (newPwd !== document.getElementById("profile-confirm-password").value) {
showError("error-profile", { code: "VALIDATION", message: "两次输入的新密码不一致" });
return;
}
var submitBtn = document.getElementById("btn-profile-password-submit");
var origText = submitBtn.textContent;
submitBtn.disabled = true;
submitBtn.textContent = "提交中…";
api("/api/v1/auth/password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
old_password: document.getElementById("profile-old-password").value,
new_password: newPwd
})
}).then(function () {
/* 改密成功:更新本地 user 状态(清除 must_change_password 标志) */
var user = getStoredUser();
if (user && user.must_change_password) {
user.must_change_password = false;
localStorage.setItem(USER_KEY, JSON.stringify(user));
}
/* 清空表单与强度提示 */
document.getElementById("profile-password-form").reset();
document.getElementById("profile-strength-hint").textContent = "";
/* 刷新账号信息展示 */
loadProfile();
alert("密码修改成功");
}).catch(function (err) {
showError("error-profile", err);
}).finally(function () {
submitBtn.disabled = false;
submitBtn.textContent = origText;
});
});
/* 个人中心退出按钮:复用顶栏 doLogout 逻辑 */
document.getElementById("btn-profile-logout").addEventListener("click", doLogout);
/* ---------- 通用 overlay 弹窗组件 ----------
openModal(id, title, contentBuilder):在 #modal-root 下动态构造 .modal-overlay>.modal-card
- 关闭路径:点遮罩 / 点取消按钮 / ESC(提交中 data-submitting=true 时全部禁用)
- 提交 loading 态:setModalSubmitting 禁用按钮 + 文案改「提交中…」
- 动态内容全 textContent,无 innerHTML */
function openModal(id, title, contentBuilder) {
closeModal(id);
var overlay = el("div", null, "modal-overlay");
overlay.id = "modal-" + id;
overlay.setAttribute("data-modal-id", id);
overlay.setAttribute("data-submitting", "false");
var card = el("div", null, "modal-card");
card.appendChild(el("h3", title, "modal-title"));
var content = el("div", null, "modal-content");
contentBuilder(content);
card.appendChild(content);
overlay.appendChild(card);
/* 点遮罩关闭(提交中不可关闭) */
overlay.addEventListener("click", function (e) {
if (e.target === overlay && overlay.getAttribute("data-submitting") !== "true") {
closeModal(id);
}
});
document.getElementById("modal-root").appendChild(overlay);
}
function closeModal(id) {
var existing = document.getElementById("modal-" + id);
if (existing && existing.parentNode) {
existing.parentNode.removeChild(existing);
}
}
function isModalSubmitting(id) {
var overlay = document.getElementById("modal-" + id);
return !!(overlay && overlay.getAttribute("data-submitting") === "true");
}
/* 提交 loading 态:data-submitting 标记 + 按钮禁用 + 文案改「提交中…」;
失败回滚时恢复按钮原始文案与可点状态 */
function setModalSubmitting(id, submitting, submitBtn) {
var overlay = document.getElementById("modal-" + id);
if (!overlay) { return; }
overlay.setAttribute("data-submitting", submitting ? "true" : "false");
if (submitBtn) {
if (submitting) {
if (!submitBtn.getAttribute("data-original-text")) {
submitBtn.setAttribute("data-original-text", submitBtn.textContent);
}
submitBtn.disabled = true;
submitBtn.textContent = "提交中…";
} else {
submitBtn.disabled = false;
var orig = submitBtn.getAttribute("data-original-text");
submitBtn.textContent = orig || submitBtn.textContent;
}
}
}
function showModelError(id, message) {
var overlay = document.getElementById("modal-" + id);
if (!overlay) { return; }
var box = overlay.querySelector(".error-bar");
if (!box) { return; }
box.textContent = message || "";
box.classList.remove("hidden");
}
function hideModelError(id) {
var overlay = document.getElementById("modal-" + id);
if (!overlay) { return; }
var box = overlay.querySelector(".error-bar");
if (!box) { return; }
box.classList.add("hidden");
box.textContent = "";
}
/* ESC 关闭最顶层弹窗(提交中不可关闭) */
document.addEventListener("keydown", function (e) {
if (e.key !== "Escape") { return; }
var modals = document.querySelectorAll(".modal-overlay");
if (modals.length === 0) { return; }
var top = modals[modals.length - 1];
if (top.getAttribute("data-submitting") === "true") { return; }
var mid = top.getAttribute("data-modal-id");
if (mid) { closeModal(mid); }
});
/* 统一 API 封装:自动注入 Authorization: Bearer <token>(登录接口除外),code !== 0 抛错;
1005 未认证/凭证无效 → 清除本地凭证并回登录卡片;1006 权限不足 → 抛给调用方在错误条展示 */
function api(path, options) {
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) {
/* 切走前停止进度区块轮询(切到其他区块即暂停) */
stopProgressPolling();
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(); }
if (targetId === "section-profile") { loadProfile(); }
}
/* 切到入库进度区块时恢复轮询 */
if (targetId === "section-progress") { startProgressPolling(); }
}
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);
});
}
/* 删除文档弹窗:复用通用弹窗组件,输入 doc_id 匹配后才可提交;
与用户管理删除保持一致的二次确认体验(不再使用原生弹窗) */
function deleteDocument(docId, title) {
hideError("error-docs");
openModal("delete-doc", "删除文档", function (content) {
var warn = el("div", null, "fallback-flag");
warn.textContent = "此操作不可恢复,将删除四层集合中的全部数据";
content.appendChild(warn);
var metaRow = el("div", null, "modal-confirm-text");
metaRow.textContent = "文档:" + (title || docId) + "doc_id=" + docId + "";
content.appendChild(metaRow);
var confirmText = el("div", null, "modal-confirm-text");
confirmText.textContent = "请输入 doc_id " + docId + " 以确认";
content.appendChild(confirmText);
var field = el("div", null, "field");
var input = el("input");
input.type = "text";
input.id = "modal-delete-doc-input";
input.setAttribute("autocomplete", "off");
field.appendChild(input);
content.appendChild(field);
var errBox = el("div", null, "error-bar hidden");
errBox.id = "modal-delete-doc-error";
content.appendChild(errBox);
var actions = el("div", null, "modal-actions");
var submitBtn = el("button", "删除", "action danger");
submitBtn.type = "button";
submitBtn.disabled = true;
var cancelBtn = el("button", "取消", "action");
cancelBtn.type = "button";
cancelBtn.addEventListener("click", function () {
if (!isModalSubmitting("delete-doc")) { closeModal("delete-doc"); }
});
input.addEventListener("input", function () {
submitBtn.disabled = (input.value !== docId);
});
submitBtn.addEventListener("click", function () {
if (input.value !== docId) {
showModelError("delete-doc", "输入的 doc_id 不匹配");
return;
}
hideModelError("delete-doc");
setModalSubmitting("delete-doc", true, submitBtn);
api("/api/v1/documents/" + encodeURIComponent(docId), { method: "DELETE" }).then(function (data) {
closeModal("delete-doc");
document.getElementById("doc-detail").classList.add("hidden");
loadDocuments(true);
loadOverview();
var status = document.getElementById("docs-status");
status.textContent = "已删除 " + (data.deleted_total || 0) + " 条数据";
}).catch(function (err) {
showModelError("delete-doc", (err && err.message) ? err.message : String(err));
setModalSubmitting("delete-doc", false, submitBtn);
});
});
actions.appendChild(submitBtn);
actions.appendChild(cancelBtn);
content.appendChild(actions);
setTimeout(function () { input.focus(); }, 0);
});
}
/* ---------- 3. 文档入库(异步任务轮询) ---------- */
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 badge = statusBadgeEl(status);
badge.id = "ingest-status-badge";
return badge;
}
/* 状态徽章构造:复用 INGEST_STATUS_TEXT 中文映射与 status-* 配色,无 id 可重复使用 */
function statusBadgeEl(status) {
var cls = "status-running";
if (status === "done") { cls = "status-done"; }
if (status === "failed") { cls = "status-failed"; }
return el("span", INGEST_STATUS_TEXT[status] || status, "status-badge " + cls);
}
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 title = document.getElementById("upload-title").value;
var source = document.getElementById("upload-source").value;
if (fileInput.files.length > 1) {
/* 批量上传:FormData 用 files 字段逐文件 append,走 upload-batch 端点 */
var batchForm = new FormData();
for (var i = 0; i < fileInput.files.length; i++) {
batchForm.append("files", fileInput.files[i]);
}
api("/api/v1/documents/upload-batch", {
method: "POST",
body: batchForm
}).then(function (data) {
renderBatchUploadResult(data);
/* 自动切到入库进度区块查看进度 */
activateSection("section-progress");
}).catch(function (err) {
showError("error-ingest", err);
}).finally(function () {
submitBtn.disabled = false;
submitBtn.textContent = "上传入库";
});
} else {
/* 单文件上传:保持原逻辑,走 upload 端点 */
var formData = new FormData();
formData.append("file", fileInput.files[0]);
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 = "上传入库";
});
}
});
/* 批量上传结果展示:成功 N 个任务 + 失败 N 个,纯 textContent 防 XSS */
function renderBatchUploadResult(data) {
var box = document.getElementById("ingest-result");
clearChildren(box);
box.classList.remove("hidden");
var tasks = data.tasks || [];
var failed = data.failed || [];
var headRow = el("div", null, "kv");
headRow.appendChild(el("span", "批量上传完成:成功 " + tasks.length + " 个任务,失败 " + failed.length + " 个"));
box.appendChild(headRow);
if (tasks.length > 0) {
var tasksRow = el("div", null, "kv");
tasksRow.appendChild(el("span", "已提交任务:", "k"));
var tasksList = tasks.map(function (t) {
return (t.filename || "") + "" + (t.task_id || "") + "";
}).join("");
tasksRow.appendChild(el("span", tasksList));
box.appendChild(tasksRow);
}
if (failed.length > 0) {
var failedRow = el("div", null, "kv");
failedRow.appendChild(el("span", "失败文件:", "k"));
var failedList = failed.map(function (f) {
return (f.filename || "") + "" + (f.error || "") + "";
}).join("");
failedRow.appendChild(el("span", failedList));
box.appendChild(failedRow);
}
}
/* ---------- 3.5 入库进度区块(2s 轮询,区块可见时轮询、切走暂停) ---------- */
var PROGRESS_POLL_INTERVAL_MS = 2000;
var progressPollTimer = null;
function stopProgressPolling() {
if (progressPollTimer !== null) {
clearInterval(progressPollTimer);
progressPollTimer = null;
}
}
function startProgressPolling() {
stopProgressPolling();
loadProgress();
progressPollTimer = setInterval(loadProgress, PROGRESS_POLL_INTERVAL_MS);
}
/* GET /documents/tasks 拉取任务列表,渲染表格与状态统计 */
function loadProgress() {
hideError("error-progress");
api("/api/v1/documents/tasks?limit=50").then(function (data) {
renderProgress(data.items || [], data.total || 0);
}).catch(function (err) { showError("error-progress", err); });
}
function renderProgress(items, total) {
var tbody = document.getElementById("progress-tbody");
clearChildren(tbody);
var running = 0, done = 0, failed = 0;
items.forEach(function (it) {
if (it.status === "done") { done += 1; }
else if (it.status === "failed") { failed += 1; }
else { running += 1; }
tbody.appendChild(buildProgressRow(it));
});
document.getElementById("progress-stats").textContent =
"总数 " + total + " · 进行中 " + running + " · 完成 " + done + " · 失败 " + failed;
if (items.length === 0) {
var tr = el("tr");
var td = el("td", "暂无入库任务", "muted");
td.setAttribute("colspan", "5");
tr.appendChild(td);
tbody.appendChild(tr);
}
}
/* 单行渲染:文件名 / 状态徽章 / 创建时间 / 更新时间 / 操作(done 查看文档+重新入库,failed 重试) */
function buildProgressRow(item) {
var tr = el("tr");
tr.appendChild(el("td", item.filename || "(未知文件)"));
var statusTd = el("td");
statusTd.appendChild(statusBadgeEl(item.status));
tr.appendChild(statusTd);
tr.appendChild(el("td", item.created_at || ""));
tr.appendChild(el("td", item.updated_at || ""));
var opsTd = el("td");
if (item.status === "done" && item.doc_id) {
var viewBtn = el("button", "查看文档", "action");
viewBtn.type = "button";
viewBtn.addEventListener("click", function () {
activateSection("section-docs");
loadDocDetail(item.doc_id);
});
var reingestBtn = el("button", "重新入库", "action");
reingestBtn.type = "button";
reingestBtn.addEventListener("click", function () {
reingestDocument(item.doc_id, item.filename);
});
opsTd.appendChild(viewBtn);
opsTd.appendChild(reingestBtn);
} else if (item.status === "failed" && item.doc_id) {
var retryBtn = el("button", "重试", "action");
retryBtn.type = "button";
retryBtn.addEventListener("click", function () {
reingestDocument(item.doc_id, item.filename);
});
opsTd.appendChild(retryBtn);
}
tr.appendChild(opsTd);
return tr;
}
/* 重新入库:POST /documents/{doc_id}/reingest,成功后刷新进度表格 */
function reingestDocument(docId, filename) {
hideError("error-progress");
api("/api/v1/documents/" + encodeURIComponent(docId) + "/reingest", {
method: "POST"
}).then(function (data) {
var stats = document.getElementById("progress-stats");
stats.textContent = "已提交「" + (filename || docId) + "」重新入库:task_id=" + (data.task_id || "");
loadProgress();
}).catch(function (err) { showError("error-progress", err); });
}
document.getElementById("btn-refresh-progress").addEventListener("click", loadProgress);
/* ---------- 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);
/* 搜索框 input + 角色筛选 change:仅本地重新过滤渲染,不发后端请求 */
document.getElementById("user-search").addEventListener("input", applyUsersFilter);
document.getElementById("user-role-filter").addEventListener("change", applyUsersFilter);
}
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); });
}
var usersCache = [];
function renderUsers(users) {
usersCache = users || [];
applyUsersFilter();
}
/* 列表搜索筛选:按用户名(包含、不区分大小写)+ 角色下拉过滤,仅本地重新渲染不发请求 */
function applyUsersFilter() {
var tbody = document.getElementById("users-tbody");
clearChildren(tbody);
var keyword = (document.getElementById("user-search").value || "").trim().toLowerCase();
var roleFilter = document.getElementById("user-role-filter").value;
var currentUser = getStoredUser();
usersCache.forEach(function (user) {
if (keyword && (user.username || "").toLowerCase().indexOf(keyword) === -1) { return; }
if (roleFilter !== "all" && user.role !== roleFilter) { return; }
tbody.appendChild(buildUserRow(user, currentUser));
});
}
/* 单行渲染:行内角色 select + 保存按钮、启用开关;当前登录 admin 自身行禁用防自锁降级 */
function buildUserRow(user, currentUser) {
var isSelf = !!(currentUser && currentUser.username === user.username);
var tr = el("tr");
tr.appendChild(el("td", user.username));
/* 角色:行内 select + 保存按钮(自身行 select disabled + 无保存按钮) */
var roleTd = el("td");
var roleSelect = el("select", null, "user-role-select");
var adminOpt = el("option", "admin");
adminOpt.value = "admin";
var userOpt = el("option", "user");
userOpt.value = "user";
roleSelect.appendChild(userOpt);
roleSelect.appendChild(adminOpt);
roleSelect.value = user.role;
if (isSelf) {
roleSelect.disabled = true;
} else {
var saveRoleBtn = el("button", "保存", "action user-role-save");
saveRoleBtn.type = "button";
saveRoleBtn.disabled = true;
saveRoleBtn.addEventListener("click", function () {
updateUserRole(user.username, roleSelect.value, saveRoleBtn);
});
roleSelect.addEventListener("change", function () {
saveRoleBtn.disabled = (roleSelect.value === user.role);
});
roleTd.appendChild(saveRoleBtn);
}
roleTd.appendChild(roleSelect);
tr.appendChild(roleTd);
tr.appendChild(el("td", user.must_change_password ? "是" : "否"));
tr.appendChild(el("td", user.created_at || ""));
/* 启用开关:当前状态文字 + 样式区分;自身行 disabled 不可点 */
var enabledTd = el("td");
var isEnabled = !!user.enabled;
var toggleBtn = el(
"button",
isEnabled ? "启用" : "禁用",
"action user-enabled-toggle" + (isEnabled ? "" : " danger")
);
toggleBtn.type = "button";
if (isSelf) {
toggleBtn.disabled = true;
toggleBtn.title = "不能禁用当前登录账号";
} else {
toggleBtn.addEventListener("click", function () {
toggleUserEnabled(user.username, isEnabled);
});
}
enabledTd.appendChild(toggleBtn);
tr.appendChild(enabledTd);
/* 操作:重置密码 + 删除(均走通用弹窗组件,无 prompt/confirm 调用) */
var opsTd = el("td");
var resetBtn = el("button", "重置密码", "action");
resetBtn.type = "button";
resetBtn.addEventListener("click", function () { resetUserPassword(user.username); });
var deleteBtn = el("button", "删除", "action danger");
deleteBtn.type = "button";
deleteBtn.addEventListener("click", function () { deleteUser(user.username, user.role); });
opsTd.appendChild(resetBtn);
opsTd.appendChild(deleteBtn);
tr.appendChild(opsTd);
return tr;
}
function showUsersStatus(text) {
var span = document.getElementById("users-status");
if (!span) { return; }
span.textContent = text || "";
if (text) {
setTimeout(function () {
if (span.textContent === text) { span.textContent = ""; }
}, 2500);
}
}
/* 行内角色保存:PATCH {role},成功刷新列表 + 状态提示;失败错误条 */
function updateUserRole(username, newRole, btn) {
hideError("error-users");
if (btn) { btn.disabled = true; }
api("/api/v1/auth/users/" + encodeURIComponent(username), {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ role: newRole })
}).then(function () {
showUsersStatus("已更新 " + username + " 的角色");
loadUsers();
}).catch(function (err) {
showError("error-users", err);
if (btn) { btn.disabled = false; }
});
}
/* 行内启用/禁用开关:PATCH {enabled: !当前},成功刷新列表 + 状态提示;失败错误条 */
function toggleUserEnabled(username, currentEnabled) {
hideError("error-users");
api("/api/v1/auth/users/" + encodeURIComponent(username), {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: !currentEnabled })
}).then(function () {
showUsersStatus("已" + (!currentEnabled ? "启用" : "禁用") + " 用户 " + username);
loadUsers();
}).catch(function (err) { showError("error-users", err); });
}
function createUser(event) {
event.preventDefault();
hideError("error-users");
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); });
}
/* 重置密码弹窗:新密码 + 确认密码 + 强度提示(<8 弱 / ≥8 中 / ≥12 强,纯文案)+
两次不一致本地拦截;提交按钮 loading 态;成功关闭弹窗 + 刷新列表 + 状态提示 */
function resetUserPassword(username) {
hideError("error-users");
openModal("reset-password", "重置用户「" + username + "」密码", function (content) {
var newField = el("div", null, "field");
newField.appendChild(el("label", "新密码(至少 8 位)"));
var newInput = el("input");
newInput.type = "password";
newInput.id = "modal-reset-new";
newInput.setAttribute("autocomplete", "new-password");
newField.appendChild(newInput);
content.appendChild(newField);
var confirmField = el("div", null, "field");
confirmField.appendChild(el("label", "确认新密码"));
var confirmInput = el("input");
confirmInput.type = "password";
confirmInput.id = "modal-reset-confirm";
confirmInput.setAttribute("autocomplete", "new-password");
confirmField.appendChild(confirmInput);
content.appendChild(confirmField);
var hint = el("div", null, "modal-strength-hint muted");
hint.id = "modal-reset-strength";
content.appendChild(hint);
var errBox = el("div", null, "error-bar hidden");
errBox.id = "modal-reset-error";
content.appendChild(errBox);
/* 强度提示:纯文案,<8 弱 / ≥8 中 / ≥12 强 */
function updateStrength() {
var pwd = newInput.value || "";
var level = "";
if (pwd.length > 0) {
if (pwd.length < 8) { level = "弱"; }
else if (pwd.length >= 12) { level = "强"; }
else { level = "中"; }
}
hint.textContent = level ? "密码强度:" + level : "";
}
newInput.addEventListener("input", updateStrength);
var actions = el("div", null, "modal-actions");
var submitBtn = el("button", "确认重置", "primary");
submitBtn.type = "button";
var cancelBtn = el("button", "取消", "action");
cancelBtn.type = "button";
cancelBtn.addEventListener("click", function () {
if (!isModalSubmitting("reset-password")) { closeModal("reset-password"); }
});
submitBtn.addEventListener("click", function () {
var newPwd = newInput.value;
var confirmPwd = confirmInput.value;
if (!newPwd) {
showModelError("reset-password", "新密码不能为空");
return;
}
/* 两次不一致本地拦截 */
if (newPwd !== confirmPwd) {
showModelError("reset-password", "两次输入的新密码不一致");
return;
}
hideModelError("reset-password");
setModalSubmitting("reset-password", true, submitBtn);
api("/api/v1/auth/users/" + encodeURIComponent(username) + "/password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ new_password: newPwd })
}).then(function () {
closeModal("reset-password");
loadUsers();
showUsersStatus("已重置 " + username + " 的密码");
}).catch(function (err) {
showModelError("reset-password", (err && err.message) ? err.message : String(err));
setModalSubmitting("reset-password", false, submitBtn);
});
});
actions.appendChild(submitBtn);
actions.appendChild(cancelBtn);
content.appendChild(actions);
setTimeout(function () { newInput.focus(); }, 0);
});
}
/* 删除用户弹窗:警告文案 + 「请输入用户名 {username} 以确认」+ 文本输入框;
输入 !== username 时删除按钮禁用;匹配后启用;提交按钮 loading 态 */
function deleteUser(username, role) {
hideError("error-users");
openModal("delete-user", "删除用户", function (content) {
var warn = el("div", null, "fallback-flag");
warn.textContent = "此操作不可恢复,将清除该用户及其全部会话";
content.appendChild(warn);
var metaRow = el("div", null, "modal-confirm-text");
metaRow.textContent = "目标用户:" + username + "(角色 " + role + "";
content.appendChild(metaRow);
var confirmText = el("div", null, "modal-confirm-text");
confirmText.textContent = "请输入用户名 " + username + " 以确认";
content.appendChild(confirmText);
var field = el("div", null, "field");
var input = el("input");
input.type = "text";
input.id = "modal-delete-input";
input.setAttribute("autocomplete", "off");
field.appendChild(input);
content.appendChild(field);
var errBox = el("div", null, "error-bar hidden");
errBox.id = "modal-delete-error";
content.appendChild(errBox);
var actions = el("div", null, "modal-actions");
var submitBtn = el("button", "删除", "action danger");
submitBtn.type = "button";
/* 初始禁用:输入匹配后才启用 */
submitBtn.disabled = true;
var cancelBtn = el("button", "取消", "action");
cancelBtn.type = "button";
cancelBtn.addEventListener("click", function () {
if (!isModalSubmitting("delete-user")) { closeModal("delete-user"); }
});
/* 输入 !== username 时删除按钮禁用;匹配后启用 */
input.addEventListener("input", function () {
submitBtn.disabled = (input.value !== username);
});
submitBtn.addEventListener("click", function () {
if (input.value !== username) {
showModelError("delete-user", "输入的用户名不匹配");
return;
}
hideModelError("delete-user");
setModalSubmitting("delete-user", true, submitBtn);
api("/api/v1/auth/users/" + encodeURIComponent(username), { method: "DELETE" }).then(function () {
closeModal("delete-user");
loadUsers();
showUsersStatus("已删除用户 " + username);
}).catch(function (err) {
showModelError("delete-user", (err && err.message) ? err.message : String(err));
setModalSubmitting("delete-user", false, submitBtn);
});
});
actions.appendChild(submitBtn);
actions.appendChild(cancelBtn);
content.appendChild(actions);
setTimeout(function () { input.focus(); }, 0);
});
}
/* ---------- 7. API 指南 ---------- */
/* 展示用静态维护清单:与后端路由表无自动同步,
测试 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, uploadField: "file", uploadMultiple: false },
{ method: "POST", path: "/api/v1/documents/upload-batch", desc: "批量文件上传入库(multipart files 字段,逐文件复用单文件逻辑,202 异步)", auth: "bearer",
query: [], pathParams: [], form: [],
body: null, upload: true, uploadField: "files", uploadMultiple: true },
{ method: "GET", path: "/api/v1/documents/tasks", desc: "入库任务列表(合并内存与 Redis 镜像,按 updated_at 降序)", auth: "bearer",
query: [
{ name: "limit", desc: "返回条数(1-100", def: "20" }
],
pathParams: [], form: [], body: null, upload: false },
{ 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: "POST", path: "/api/v1/documents/{doc_id}/reingest", desc: "重新入库(读原文件→删旧数据→提交新任务,生成新 doc_id)", 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) {
var fieldName = ep.uploadField || "file";
parts.push('-F "' + fieldName + '=@/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();
/* uploadField 默认 file;批量上传用 files 字段逐文件 append */
var upField = ep.uploadField || "file";
for (var fi = 0; fi < fileInput.files.length; fi++) {
formData.append(upField, fileInput.files[fi]);
}
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" + (ep.uploadMultiple ? ",可多选" : "") + ""));
var fileInput = el("input");
fileInput.type = "file";
fileInput.className = "try-file";
fileInput.accept = ".txt,.md,.html,.htm,.pdf,.docx";
if (ep.uploadMultiple) { fileInput.setAttribute("multiple", ""); }
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>