fix: 修复会话鉴权「登录后 token 无效」并补齐 NAS 部署流程
- deps.py: _create_redis_client 增加同步 ping 校验,Redis 不可达时正确降级为内存模式 - main.py: lifespan 复用 deps 的 UserStore 单例,避免 admin 与 API 请求实例不一致 - 前端: 强制改密弹窗(must_change_password 用户)、兼容新旧登录返回格式、markPasswordChanged - 新增 NAS SSH 部署脚本与批处理测试;gitignore 前端构建产物
This commit is contained in:
+221
-25
@@ -259,6 +259,7 @@
|
||||
<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>
|
||||
@@ -340,8 +341,8 @@
|
||||
<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>
|
||||
<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>
|
||||
@@ -356,6 +357,21 @@
|
||||
<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>
|
||||
@@ -505,6 +521,7 @@ function showLogin() {
|
||||
document.getElementById("password-overlay").classList.add("hidden");
|
||||
document.getElementById("user-area").classList.add("hidden");
|
||||
loadedOnce = {};
|
||||
stopProgressPolling();
|
||||
unmountUsersSection();
|
||||
}
|
||||
|
||||
@@ -858,6 +875,8 @@ function truncate(text, maxLen) {
|
||||
var loadedOnce = {};
|
||||
|
||||
function activateSection(targetId) {
|
||||
/* 切走前停止进度区块轮询(切到其他区块即暂停) */
|
||||
stopProgressPolling();
|
||||
var sections = document.querySelectorAll("main section");
|
||||
sections.forEach(function (sec) {
|
||||
sec.classList.toggle("hidden", sec.id !== targetId);
|
||||
@@ -875,6 +894,8 @@ function activateSection(targetId) {
|
||||
if (targetId === "section-api-guide") { renderApiGuide(); }
|
||||
if (targetId === "section-profile") { loadProfile(); }
|
||||
}
|
||||
/* 切到入库进度区块时恢复轮询 */
|
||||
if (targetId === "section-progress") { startProgressPolling(); }
|
||||
}
|
||||
|
||||
document.getElementById("nav").addEventListener("click", function (event) {
|
||||
@@ -1158,12 +1179,17 @@ function restoreIngestButton() {
|
||||
}
|
||||
|
||||
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"; }
|
||||
var badge = el("span", INGEST_STATUS_TEXT[status] || status, "status-badge " + cls);
|
||||
badge.id = "ingest-status-badge";
|
||||
return badge;
|
||||
return el("span", INGEST_STATUS_TEXT[status] || status, "status-badge " + cls);
|
||||
}
|
||||
|
||||
function updateIngestStatus(status) {
|
||||
@@ -1290,26 +1316,178 @@ document.getElementById("upload-form").addEventListener("submit", function (even
|
||||
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 = "上传入库";
|
||||
});
|
||||
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) {
|
||||
@@ -1768,7 +1946,15 @@ var API_GUIDE = [
|
||||
{ name: "title", desc: "可选标题,默认取文件名去扩展", def: "" },
|
||||
{ name: "source", desc: "可选来源标识,默认 file:原文件名", def: "" }
|
||||
],
|
||||
body: null, upload: true },
|
||||
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 附 result,failed 附 error)", auth: "none",
|
||||
query: [],
|
||||
pathParams: [{ name: "task_id", desc: "入库任务 ID", def: "" }],
|
||||
@@ -1787,6 +1973,10 @@ var API_GUIDE = [
|
||||
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",
|
||||
@@ -1841,7 +2031,8 @@ function buildCurl(ep) {
|
||||
parts.push('-H "Authorization: Bearer <TOKEN>"');
|
||||
}
|
||||
if (ep.upload) {
|
||||
parts.push('-F "file=@/path/to/file.md"');
|
||||
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) + "'");
|
||||
@@ -1964,7 +2155,11 @@ function sendApiTry(idx, panel) {
|
||||
}
|
||||
/* FormData 由浏览器自动生成 multipart 边界,不设置 Content-Type 头 */
|
||||
var formData = new FormData();
|
||||
formData.append("file", fileInput.files[0]);
|
||||
/* 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();
|
||||
@@ -2044,11 +2239,12 @@ function renderApiItem(ep, idx) {
|
||||
if (ep.upload) {
|
||||
renderTryInputs(panel, "form", ep.form);
|
||||
var fileField = el("div", null, "field");
|
||||
fileField.appendChild(el("label", "文件(.txt/.md/.html/.htm/.pdf/.docx)"));
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user