e2e8e6829d
1. 新增将文档元数据存入L1向量库的功能 2. 新增文档文件下载API接口,支持路径安全校验 3. 后端文档详情接口新增文件信息返回字段 4. 管理后台页面新增原始文件信息展示与下载链接
1580 lines
60 KiB
HTML
1580 lines
60 KiB
HTML
<!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/.docx,202 异步)", auth: "bearer",
|
||
query: [], pathParams: [],
|
||
form: [
|
||
{ name: "title", desc: "可选标题,默认取文件名去扩展", def: "" },
|
||
{ name: "source", desc: "可选来源标识,默认 file:原文件名", def: "" }
|
||
],
|
||
body: null, upload: true },
|
||
{ method: "GET", path: "/api/v1/documents/tasks/{task_id}", desc: "入库任务状态查询(done 附 result,failed 附 error)", auth: "none",
|
||
query: [],
|
||
pathParams: [{ name: "task_id", desc: "入库任务 ID", def: "" }],
|
||
form: [], body: null, upload: false },
|
||
{ method: "GET", path: "/api/v1/documents", desc: "文档列表(limit/offset 分页)", auth: "none",
|
||
query: [
|
||
{ name: "limit", desc: "每页条数(1-100)", def: "20" },
|
||
{ name: "offset", desc: "分页游标(上一页返回的 next_offset)", def: "" }
|
||
],
|
||
pathParams: [], form: [], body: null, upload: false },
|
||
{ method: "GET", path: "/api/v1/documents/{doc_id}", desc: "文档详情(L1 记录 + L2/L3 节点 + chunks 数量)", auth: "none",
|
||
query: [],
|
||
pathParams: [{ name: "doc_id", desc: "文档 ID", def: "" }],
|
||
form: [], body: null, upload: false },
|
||
{ method: "DELETE", path: "/api/v1/documents/{doc_id}", desc: "删除文档(四层集合全量删除,幂等)", auth: "bearer",
|
||
query: [],
|
||
pathParams: [{ name: "doc_id", desc: "文档 ID", def: "" }],
|
||
form: [], body: null, upload: false },
|
||
{ method: "GET", path: "/api/v1/knowledge/categories", desc: "知识分类类目集", auth: "none",
|
||
query: [], pathParams: [], form: [], body: null, upload: false },
|
||
{ method: "GET", path: "/api/v1/knowledge/stats", desc: "统计(四层点数 + 类目分布 + uncategorized 数)", auth: "none",
|
||
query: [], pathParams: [], form: [], body: null, upload: false },
|
||
{ method: "POST", path: "/api/v1/auth/login", desc: "用户名密码登录,成功签发 Bearer token(session TTL 12h)", auth: "none",
|
||
query: [], pathParams: [], form: [],
|
||
body: { username: "admin", password: "你的密码" }, upload: false },
|
||
{ method: "POST", path: "/api/v1/auth/logout", desc: "退出登录(删除当前 session)", auth: "bearer",
|
||
query: [], pathParams: [], form: [], body: null, upload: false },
|
||
{ method: "POST", path: "/api/v1/auth/password", desc: "修改自己的密码(must_change_password 用户唯一可用接口)", auth: "bearer",
|
||
query: [], pathParams: [], form: [],
|
||
body: { old_password: "旧密码", new_password: "新密码至少8位" }, upload: false },
|
||
{ method: "GET", path: "/api/v1/auth/me", desc: "当前登录用户信息(脱敏)", auth: "bearer",
|
||
query: [], pathParams: [], form: [], body: null, upload: false },
|
||
{ method: "GET", path: "/api/v1/auth/users", desc: "用户列表(脱敏,不含 password_hash/salt)", auth: "admin",
|
||
query: [], pathParams: [], form: [], body: null, upload: false },
|
||
{ method: "POST", path: "/api/v1/auth/users", desc: "创建用户(重名/弱密码 1001)", auth: "admin",
|
||
query: [], pathParams: [], form: [],
|
||
body: { username: "newuser", password: "初始密码至少8位", role: "user" }, upload: false },
|
||
{ method: "POST", path: "/api/v1/auth/users/{username}/password", desc: "重置指定用户密码(成功后清除其全部 session)", auth: "admin",
|
||
query: [],
|
||
pathParams: [{ name: "username", desc: "目标用户名", def: "" }],
|
||
form: [],
|
||
body: { new_password: "新密码至少8位" }, upload: false },
|
||
{ method: "DELETE", path: "/api/v1/auth/users/{username}", desc: "删除用户并清其 session(不能删自己/最后一个 admin)", auth: "admin",
|
||
query: [],
|
||
pathParams: [{ name: "username", desc: "目标用户名", def: "" }],
|
||
form: [], body: null, upload: false }
|
||
];
|
||
|
||
var API_AUTH_LABELS = { none: "免登录", bearer: "需登录", admin: "仅 admin" };
|
||
|
||
/* path 模板占位替换:values 为空时回退默认值,再回退 {name} 占位(供 curl 展示) */
|
||
function substitutePath(ep, values) {
|
||
var p = ep.path;
|
||
ep.pathParams.forEach(function (pp) {
|
||
var v = values[pp.name] !== undefined && values[pp.name] !== "" ? values[pp.name] : (pp.def || "{" + pp.name + "}");
|
||
p = p.split("{" + pp.name + "}").join(v);
|
||
});
|
||
return p;
|
||
}
|
||
|
||
function buildCurl(ep) {
|
||
var url = location.origin + substitutePath(ep, {});
|
||
var qs = [];
|
||
ep.query.forEach(function (q) {
|
||
if (q.def) { qs.push(q.name + "=" + encodeURIComponent(q.def)); }
|
||
});
|
||
if (qs.length) { url += "?" + qs.join("&"); }
|
||
var parts = ['curl -X ' + ep.method + ' "' + url + '"'];
|
||
if (ep.auth !== "none") {
|
||
parts.push('-H "Authorization: Bearer <TOKEN>"');
|
||
}
|
||
if (ep.upload) {
|
||
parts.push('-F "file=@/path/to/file.md"');
|
||
} else if (ep.body) {
|
||
parts.push('-H "Content-Type: application/json"');
|
||
parts.push("-d '" + JSON.stringify(ep.body) + "'");
|
||
}
|
||
return parts.join(" \\\n ");
|
||
}
|
||
|
||
/* 复制到剪贴板:navigator.clipboard 不可用时降级为 textarea 选中复制 */
|
||
function copyText(text, btn) {
|
||
function done() {
|
||
btn.textContent = "已复制";
|
||
setTimeout(function () { btn.textContent = "复制"; }, 1500);
|
||
}
|
||
function fallback() {
|
||
var ta = document.createElement("textarea");
|
||
ta.value = text;
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
try { document.execCommand("copy"); } catch (e) { /* 复制失败不阻塞 */ }
|
||
document.body.removeChild(ta);
|
||
done();
|
||
}
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(text).then(done, fallback);
|
||
} else {
|
||
fallback();
|
||
}
|
||
}
|
||
|
||
function renderParamTable(item, title, params) {
|
||
if (!params.length) { return; }
|
||
item.appendChild(el("h4", title));
|
||
var table = el("table");
|
||
var headTr = el("tr");
|
||
headTr.appendChild(el("th", "名称"));
|
||
headTr.appendChild(el("th", "说明"));
|
||
headTr.appendChild(el("th", "默认"));
|
||
var thead = el("thead");
|
||
thead.appendChild(headTr);
|
||
table.appendChild(thead);
|
||
var tbody = el("tbody");
|
||
params.forEach(function (p) {
|
||
var tr = el("tr");
|
||
tr.appendChild(el("td", p.name));
|
||
tr.appendChild(el("td", p.desc || ""));
|
||
tr.appendChild(el("td", p.def || "(空)"));
|
||
tbody.appendChild(tr);
|
||
});
|
||
table.appendChild(tbody);
|
||
item.appendChild(table);
|
||
}
|
||
|
||
/* 试一下面板内按 kind(path/query/form)渲染参数输入框 */
|
||
function renderTryInputs(panel, kind, params) {
|
||
params.forEach(function (p) {
|
||
var field = el("div", null, "field");
|
||
field.appendChild(el("label", p.name + (p.desc ? "(" + p.desc + ")" : "")));
|
||
var input = el("input");
|
||
input.type = "text";
|
||
input.className = "try-" + kind + "-input";
|
||
input.setAttribute("data-param", p.name);
|
||
input.value = p.def || "";
|
||
input.placeholder = p.desc || p.name;
|
||
field.appendChild(input);
|
||
panel.appendChild(field);
|
||
});
|
||
}
|
||
|
||
function renderTryResult(panel, status, elapsed, text) {
|
||
var result = panel.querySelector(".try-result");
|
||
result.querySelector(".try-status").textContent = String(status);
|
||
result.querySelector(".try-elapsed").textContent = elapsed + " ms";
|
||
var pretty = text;
|
||
try { pretty = JSON.stringify(JSON.parse(text), null, 2); } catch (e) { /* 非 JSON 响应原样展示 */ }
|
||
result.querySelector(".try-result-pre").textContent = pretty;
|
||
result.classList.remove("hidden");
|
||
}
|
||
|
||
function sendApiTry(idx, panel) {
|
||
var ep = API_GUIDE[idx];
|
||
var errBox = panel.querySelector(".try-error");
|
||
var sendBtn = panel.querySelector(".try-send");
|
||
function fail(message) {
|
||
errBox.textContent = message;
|
||
errBox.classList.remove("hidden");
|
||
}
|
||
errBox.classList.add("hidden");
|
||
|
||
/* path 参数:必填,替换 path 模板占位 */
|
||
var pathValues = {};
|
||
var pathInputs = panel.querySelectorAll(".try-path-input");
|
||
for (var i = 0; i < pathInputs.length; i++) {
|
||
var pName = pathInputs[i].getAttribute("data-param");
|
||
var pVal = pathInputs[i].value.trim();
|
||
if (!pVal) {
|
||
fail("请填写 path 参数:" + pName);
|
||
return;
|
||
}
|
||
pathValues[pName] = encodeURIComponent(pVal);
|
||
}
|
||
var url = substitutePath(ep, pathValues);
|
||
|
||
/* query 参数:仅拼接非空值 */
|
||
var pairs = [];
|
||
var queryInputs = panel.querySelectorAll(".try-query-input");
|
||
for (var j = 0; j < queryInputs.length; j++) {
|
||
var qVal = queryInputs[j].value.trim();
|
||
if (qVal) {
|
||
pairs.push(queryInputs[j].getAttribute("data-param") + "=" + encodeURIComponent(qVal));
|
||
}
|
||
}
|
||
if (pairs.length) { url += "?" + pairs.join("&"); }
|
||
|
||
var options = { method: ep.method, headers: {} };
|
||
if (ep.upload) {
|
||
var fileInput = panel.querySelector(".try-file");
|
||
if (!fileInput.files || fileInput.files.length === 0) {
|
||
fail("请选择要上传的文件");
|
||
return;
|
||
}
|
||
/* FormData 由浏览器自动生成 multipart 边界,不设置 Content-Type 头 */
|
||
var formData = new FormData();
|
||
formData.append("file", fileInput.files[0]);
|
||
var formInputs = panel.querySelectorAll(".try-form-input");
|
||
for (var k = 0; k < formInputs.length; k++) {
|
||
var fVal = formInputs[k].value.trim();
|
||
if (fVal) { formData.append(formInputs[k].getAttribute("data-param"), fVal); }
|
||
}
|
||
options.body = formData;
|
||
} else if (ep.body) {
|
||
var raw = panel.querySelector(".try-body").value;
|
||
try {
|
||
JSON.parse(raw);
|
||
} catch (e) {
|
||
fail("Body 不是合法 JSON,未发送请求:" + (e && e.message ? e.message : e));
|
||
return;
|
||
}
|
||
options.headers["Content-Type"] = "application/json";
|
||
options.body = raw;
|
||
}
|
||
if (ep.auth !== "none") {
|
||
var token = getToken();
|
||
if (token) { options.headers["Authorization"] = "Bearer " + token; }
|
||
}
|
||
|
||
sendBtn.disabled = true;
|
||
var started = performance.now();
|
||
fetch(url, options).then(function (resp) {
|
||
var elapsed = Math.round(performance.now() - started);
|
||
return resp.text().then(function (text) {
|
||
renderTryResult(panel, resp.status, elapsed, text);
|
||
});
|
||
}).catch(function (err) {
|
||
renderTryResult(panel, "请求失败", Math.round(performance.now() - started),
|
||
String(err && err.message ? err.message : err));
|
||
}).finally(function () {
|
||
sendBtn.disabled = false;
|
||
});
|
||
}
|
||
|
||
function renderApiItem(ep, idx) {
|
||
var item = el("div", null, "api-item");
|
||
|
||
var head = el("div", null, "api-head");
|
||
head.appendChild(el("span", ep.method, "method-badge method-" + ep.method.toLowerCase()));
|
||
var code = el("code", ep.path);
|
||
head.appendChild(code);
|
||
head.appendChild(el("span", API_AUTH_LABELS[ep.auth] || ep.auth, "auth-badge auth-" + ep.auth));
|
||
item.appendChild(head);
|
||
item.appendChild(el("div", ep.desc, "api-desc"));
|
||
|
||
renderParamTable(item, "Path 参数", ep.pathParams);
|
||
renderParamTable(item, "Query 参数", ep.query);
|
||
renderParamTable(item, "表单字段", ep.form);
|
||
|
||
if (ep.body) {
|
||
item.appendChild(el("h4", "Body 示例(JSON)"));
|
||
item.appendChild(el("pre", JSON.stringify(ep.body, null, 2), "json-pre"));
|
||
}
|
||
|
||
item.appendChild(el("h4", "curl 示例"));
|
||
var curlBox = el("div", null, "curl-box");
|
||
var curlPre = el("pre", buildCurl(ep), "curl-pre");
|
||
var copyBtn = el("button", "复制", "action curl-copy");
|
||
copyBtn.type = "button";
|
||
copyBtn.addEventListener("click", function () { copyText(curlPre.textContent, copyBtn); });
|
||
curlBox.appendChild(copyBtn);
|
||
curlBox.appendChild(curlPre);
|
||
item.appendChild(curlBox);
|
||
|
||
/* 「试一下」面板:只能从清单展开,不提供任意 URL 输入框 */
|
||
var toggleBtn = el("button", "试一下", "action try-toggle");
|
||
toggleBtn.type = "button";
|
||
var panel = el("div", null, "try-panel hidden");
|
||
toggleBtn.addEventListener("click", function () { panel.classList.toggle("hidden"); });
|
||
item.appendChild(toggleBtn);
|
||
|
||
renderTryInputs(panel, "path", ep.pathParams);
|
||
renderTryInputs(panel, "query", ep.query);
|
||
if (ep.upload) {
|
||
renderTryInputs(panel, "form", ep.form);
|
||
var fileField = el("div", null, "field");
|
||
fileField.appendChild(el("label", "文件(.txt/.md/.html/.htm/.pdf/.docx)"));
|
||
var fileInput = el("input");
|
||
fileInput.type = "file";
|
||
fileInput.className = "try-file";
|
||
fileInput.accept = ".txt,.md,.html,.htm,.pdf,.docx";
|
||
fileField.appendChild(fileInput);
|
||
panel.appendChild(fileField);
|
||
}
|
||
if (ep.body) {
|
||
var bodyField = el("div", null, "field");
|
||
bodyField.appendChild(el("label", "Body(JSON,可编辑)"));
|
||
bodyField.appendChild(el("textarea", JSON.stringify(ep.body, null, 2), "try-body"));
|
||
panel.appendChild(bodyField);
|
||
}
|
||
|
||
var sendBtn = el("button", "发送", "primary try-send");
|
||
sendBtn.type = "button";
|
||
sendBtn.addEventListener("click", function () { sendApiTry(idx, panel); });
|
||
panel.appendChild(sendBtn);
|
||
panel.appendChild(el("div", null, "error-bar hidden try-error"));
|
||
|
||
var result = el("div", null, "result-box hidden try-result");
|
||
var statusKv = el("div", null, "kv");
|
||
statusKv.appendChild(el("span", "状态码", "k"));
|
||
statusKv.appendChild(el("span", null, "try-status"));
|
||
result.appendChild(statusKv);
|
||
var elapsedKv = el("div", null, "kv");
|
||
elapsedKv.appendChild(el("span", "耗时", "k"));
|
||
elapsedKv.appendChild(el("span", null, "try-elapsed"));
|
||
result.appendChild(elapsedKv);
|
||
result.appendChild(el("pre", null, "try-result-pre"));
|
||
panel.appendChild(result);
|
||
|
||
item.appendChild(panel);
|
||
return item;
|
||
}
|
||
|
||
function renderApiGuide() {
|
||
var container = document.getElementById("api-guide-list");
|
||
clearChildren(container);
|
||
API_GUIDE.forEach(function (ep, idx) {
|
||
container.appendChild(renderApiItem(ep, idx));
|
||
});
|
||
}
|
||
|
||
/* ---------- 初始化 ---------- */
|
||
|
||
(function init() {
|
||
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>
|