- M1-M2: 数据层与核心API(DuckDB, entries/tags/comments/search服务) - M3: SSR页面(EJS模板,接真实数据) - M4: 管理后台(登录/用户/内容源/AI ingest) - M5: 采集层(scheduler+rss/html/eryajf适配器) - M6: MCP只读接口(Streamable HTTP, 4工具) - M7: Docker部署(Dockerfile+docker-compose) - 测试: 10个用例全部通过 - 修复: DuckDB连接参数绑定、状态机迁移、标签树操作
207 lines
7.6 KiB
JavaScript
207 lines
7.6 KiB
JavaScript
(function () {
|
||
"use strict";
|
||
|
||
async function api(url, body, method) {
|
||
var res = await fetch(url, {
|
||
method: method || "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: body === undefined ? undefined : JSON.stringify(body),
|
||
});
|
||
var json = null;
|
||
try { json = await res.json(); } catch (e) {}
|
||
if (!res.ok || (json && json.ok === false)) {
|
||
if (res.status === 401) { location.href = "/login"; return new Promise(function () {}); }
|
||
throw new Error((json && json.error && json.error.message) || "请求失败(" + res.status + ")");
|
||
}
|
||
return json;
|
||
}
|
||
|
||
function toast(msg) {
|
||
var el = document.querySelector(".toast");
|
||
if (!el) return;
|
||
el.textContent = msg;
|
||
el.classList.add("show");
|
||
clearTimeout(el._t);
|
||
el._t = setTimeout(function () { el.classList.remove("show"); }, 2600);
|
||
}
|
||
|
||
function refresh() {
|
||
setTimeout(function () { location.reload(); }, 500);
|
||
}
|
||
|
||
document.querySelectorAll(".admin-nav button[data-section]").forEach(function (btn) {
|
||
btn.addEventListener("click", function () {
|
||
document.querySelectorAll(".admin-nav button").forEach(function (b) {
|
||
b.setAttribute("aria-current", "false");
|
||
});
|
||
btn.setAttribute("aria-current", "true");
|
||
var id = btn.getAttribute("data-section");
|
||
document.querySelectorAll(".admin-main > section").forEach(function (s) {
|
||
s.classList.toggle("active", s.id === id);
|
||
});
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll(".tree-row[data-tree]").forEach(function (row) {
|
||
row.addEventListener("click", function (e) {
|
||
if (e.target.closest("button[data-tag-action]")) return;
|
||
var target = document.getElementById(row.getAttribute("data-tree"));
|
||
if (!target) return;
|
||
var open = target.classList.toggle("open");
|
||
row.setAttribute("aria-expanded", open ? "true" : "false");
|
||
});
|
||
});
|
||
|
||
document.addEventListener("click", async function (e) {
|
||
var btn = e.target.closest("[data-action]");
|
||
if (!btn) return;
|
||
var action = btn.getAttribute("data-action");
|
||
|
||
try {
|
||
if (action === "tag-add") {
|
||
var parentId = btn.getAttribute("data-parent-id");
|
||
var name = prompt(parentId ? "新增子标签名称:" : "新增顶层标签组名称:");
|
||
if (!name) return;
|
||
await api("/api/tags", { name: name, parentId: parentId ? Number(parentId) : null });
|
||
toast("标签已创建"); refresh();
|
||
}
|
||
|
||
if (action === "tag-rename") {
|
||
var name2 = prompt("重命名为:", btn.getAttribute("data-name"));
|
||
if (!name2 || name2 === btn.getAttribute("data-name")) return;
|
||
await api("/api/tags/" + btn.getAttribute("data-id"), { name: name2 }, "PATCH");
|
||
toast("已重命名"); refresh();
|
||
}
|
||
|
||
if (action === "tag-move") {
|
||
var target3 = prompt("移动到哪个标签之下?填其 ID(留空移到顶层):\n" + btn.getAttribute("data-ids"));
|
||
if (target3 === null) return;
|
||
await api("/api/tags/" + btn.getAttribute("data-id"),
|
||
{ parentId: target3.trim() ? Number(target3.trim()) : null }, "PATCH");
|
||
toast("已移动"); refresh();
|
||
}
|
||
|
||
if (action === "tag-delete") {
|
||
if (!confirm("删除该标签?其子标签将上移一级,关联关系解除。")) return;
|
||
await api("/api/tags/" + btn.getAttribute("data-id"), undefined, "DELETE");
|
||
toast("已删除"); refresh();
|
||
}
|
||
|
||
if (action === "entry-status") {
|
||
await api("/api/entries/" + btn.getAttribute("data-id") + "/status",
|
||
{ status: btn.getAttribute("data-status") }, "PATCH");
|
||
toast("状态已更新"); refresh();
|
||
}
|
||
|
||
if (action === "entry-delete") {
|
||
if (!confirm("物理删除该条目?此操作不可恢复。")) return;
|
||
await api("/api/entries/" + btn.getAttribute("data-id"), undefined, "DELETE");
|
||
toast("已删除"); refresh();
|
||
}
|
||
|
||
if (action === "comment-delete") {
|
||
if (!confirm("删除该评论?")) return;
|
||
await api("/api/comments/" + btn.getAttribute("data-id"), undefined, "DELETE");
|
||
toast("已删除"); refresh();
|
||
}
|
||
|
||
if (action === "user-disable") {
|
||
var status = btn.getAttribute("data-status") === "active" ? "disabled" : "active";
|
||
await api("/api/admin/users/" + btn.getAttribute("data-id") + "/status", { status: status }, "PATCH");
|
||
toast("账号状态已更新"); refresh();
|
||
}
|
||
} catch (err) {
|
||
toast(err.message);
|
||
}
|
||
});
|
||
|
||
var userAddForm = document.getElementById("user-add-form");
|
||
if (userAddForm) {
|
||
userAddForm.addEventListener("submit", function (e) {
|
||
e.preventDefault();
|
||
api("/api/admin/users", {
|
||
username: userAddForm.username.value.trim(),
|
||
password: userAddForm.password.value,
|
||
role: userAddForm.role.value,
|
||
})
|
||
.then(function () { toast("账号已创建"); refresh(); })
|
||
.catch(function (err) { toast(err.message); });
|
||
});
|
||
}
|
||
|
||
var aiKeyForm = document.getElementById("ai-key-form");
|
||
if (aiKeyForm) {
|
||
aiKeyForm.addEventListener("submit", function (e) {
|
||
e.preventDefault();
|
||
api("/api/admin/ai-key", { key: aiKeyForm.key.value.trim() }, "PUT")
|
||
.then(function () { toast("AI 录入密钥已更新"); refresh(); })
|
||
.catch(function (err) { toast(err.message); });
|
||
});
|
||
}
|
||
|
||
document.querySelectorAll("form.source-config").forEach(function (form) {
|
||
form.addEventListener("submit", function (e) {
|
||
e.preventDefault();
|
||
var id = form.getAttribute("data-id");
|
||
api("/api/admin/sources/" + id, {
|
||
name: form.name.value,
|
||
url: form.url.value,
|
||
adapter: form.adapter.value,
|
||
cron_expr: form.cron_expr.value,
|
||
enabled: form.enabled.checked,
|
||
}, "PATCH")
|
||
.then(function () { toast("内容源已保存"); refresh(); })
|
||
.catch(function (err) { toast(err.message); });
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll("[data-source-run]").forEach(function (btn) {
|
||
btn.addEventListener("click", async function () {
|
||
var id = btn.getAttribute("data-source-run");
|
||
btn.disabled = true;
|
||
try {
|
||
var json = await api("/api/admin/sources/" + id + "/run", {});
|
||
var s = json.data;
|
||
toast(s.error
|
||
? "拉取失败:" + s.error
|
||
: "完成:发现 " + s.found + " · 新增 " + s.created + " · 跳过 " + s.skipped);
|
||
if (!s.error) refresh();
|
||
} catch (err) {
|
||
toast(err.message);
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll("[data-source-runs]").forEach(function (btn) {
|
||
btn.addEventListener("click", async function () {
|
||
var box = document.getElementById("runs-" + btn.getAttribute("data-source-runs"));
|
||
if (!box) return;
|
||
if (!box.hidden) { box.hidden = true; return; }
|
||
try {
|
||
var json = await api("/api/admin/sources/" + btn.getAttribute("data-source-runs") + "/runs", undefined, "GET");
|
||
box.textContent = JSON.stringify(json.data, null, 2).slice(0, 3000);
|
||
box.hidden = false;
|
||
} catch (err) {
|
||
toast(err.message);
|
||
}
|
||
});
|
||
});
|
||
|
||
var sourceAddForm = document.getElementById("source-add-form");
|
||
if (sourceAddForm) {
|
||
sourceAddForm.addEventListener("submit", function (e) {
|
||
e.preventDefault();
|
||
api("/api/admin/sources", {
|
||
name: sourceAddForm.name.value.trim(),
|
||
url: sourceAddForm.url.value.trim(),
|
||
adapter: sourceAddForm.adapter.value,
|
||
cron_expr: sourceAddForm.cron_expr.value.trim(),
|
||
})
|
||
.then(function () { toast("拉取源已创建"); refresh(); })
|
||
.catch(function (err) { toast(err.message); });
|
||
});
|
||
}
|
||
})();
|