feat: 新增多格式文件上传入库与认证体系
- 新增 JWT 认证模块,支持登录/注册/用户管理 - 新增文件上传接口,支持 .txt/.md/.html/.pdf/.docx 等格式解析入库 - 新增检索结果 AI 总结功能 - 新增文本去重缓存机制 - 新增全局认证夹具简化测试 - 新增配置项与环境变量支持 - 完善文档与测试覆盖
This commit is contained in:
+199
-3
@@ -115,11 +115,56 @@
|
||||
.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; }
|
||||
.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; }
|
||||
</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>
|
||||
<header>
|
||||
<h1>知识库管理后台</h1>
|
||||
<div id="user-area" class="user-area hidden">
|
||||
<span class="muted" id="user-name"></span>
|
||||
<button type="button" class="action" id="btn-logout">登出</button>
|
||||
</div>
|
||||
<nav id="nav">
|
||||
<button type="button" data-target="section-overview" class="active">概览</button>
|
||||
<button type="button" data-target="section-docs">文档管理</button>
|
||||
@@ -174,6 +219,22 @@
|
||||
</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>
|
||||
|
||||
@@ -189,6 +250,9 @@
|
||||
<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>
|
||||
@@ -235,14 +299,76 @@ function hideError(boxId) {
|
||||
document.getElementById(boxId).classList.add("hidden");
|
||||
}
|
||||
|
||||
/* 统一 API 封装:code !== 0 抛错,网络错误同样捕获 */
|
||||
/* ---------- 认证 token 管理 ---------- */
|
||||
function getToken() { return localStorage.getItem("qmd_token") || ""; }
|
||||
function setToken(t) { localStorage.setItem("qmd_token", t); }
|
||||
function clearToken() { localStorage.removeItem("qmd_token"); }
|
||||
|
||||
function showLogin() {
|
||||
document.getElementById("login-overlay").classList.remove("hidden");
|
||||
document.getElementById("user-area").classList.add("hidden");
|
||||
loadedOnce = {};
|
||||
}
|
||||
|
||||
function afterLogin(user) {
|
||||
document.getElementById("login-overlay").classList.add("hidden");
|
||||
document.getElementById("user-area").classList.remove("hidden");
|
||||
document.getElementById("user-name").textContent = user.username + " (" + user.role + ")";
|
||||
loadedOnce = {};
|
||||
activateSection("section-overview");
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
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;
|
||||
}
|
||||
setToken(body.data.access_token);
|
||||
afterLogin(body.data.user);
|
||||
}).catch(function (err) {
|
||||
showError("error-login", { code: "NETWORK", message: "登录请求失败: " + (err && err.message ? err.message : err) });
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("btn-logout").addEventListener("click", function () {
|
||||
clearToken();
|
||||
showLogin();
|
||||
});
|
||||
|
||||
/* 统一 API 封装:自动注入 token,code !== 0 抛错,未认证跳登录 */
|
||||
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) {
|
||||
if (resp.status === 401) {
|
||||
clearToken();
|
||||
showLogin();
|
||||
throw { code: 1003, message: "未认证或登录已过期,请重新登录" };
|
||||
}
|
||||
return resp.json().catch(function () {
|
||||
throw { code: "HTTP " + resp.status, message: "响应解析失败" };
|
||||
});
|
||||
}).then(function (body) {
|
||||
if (body.code !== 0) {
|
||||
if (body.code === 1003 || body.code === 1005) {
|
||||
clearToken();
|
||||
showLogin();
|
||||
}
|
||||
throw { code: body.code, message: body.message };
|
||||
}
|
||||
return body.data;
|
||||
@@ -612,6 +738,38 @@ document.getElementById("ingest-form").addEventListener("submit", function (even
|
||||
});
|
||||
});
|
||||
|
||||
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) {
|
||||
@@ -621,7 +779,8 @@ document.getElementById("search-form").addEventListener("submit", function (even
|
||||
submitBtn.disabled = true;
|
||||
var payload = {
|
||||
query: document.getElementById("search-query").value,
|
||||
top_k: parseInt(document.getElementById("search-topk").value, 10) || 5
|
||||
top_k: parseInt(document.getElementById("search-topk").value, 10) || 5,
|
||||
summarize: document.getElementById("search-summarize").checked
|
||||
};
|
||||
api("/api/v1/search", {
|
||||
method: "POST",
|
||||
@@ -636,6 +795,13 @@ document.getElementById("search-form").addEventListener("submit", function (even
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -650,6 +816,26 @@ function renderSearchResult(data) {
|
||||
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) {
|
||||
@@ -686,7 +872,17 @@ function loadCategories() {
|
||||
|
||||
/* ---------- 初始化 ---------- */
|
||||
|
||||
activateSection("section-overview");
|
||||
(function init() {
|
||||
if (!getToken()) {
|
||||
showLogin();
|
||||
return;
|
||||
}
|
||||
api("/api/v1/auth/me").then(function (user) {
|
||||
afterLogin(user);
|
||||
}).catch(function () {
|
||||
showLogin();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user