feat: awesome index 全功能实现

- 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连接参数绑定、状态机迁移、标签树操作
This commit is contained in:
cheney
2026-08-28 09:32:48 +08:00
parent c47bb15d8c
commit dec103546e
51 changed files with 7298 additions and 0 deletions
+1839
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect x="4" y="4" width="20" height="20" rx="5" fill="#2436E8"/><rect x="10" y="10" width="18" height="18" rx="5" fill="none" stroke="#FF4D2E" stroke-width="2.5"/></svg>

After

Width:  |  Height:  |  Size: 230 B

+206
View File
@@ -0,0 +1,206 @@
(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); });
});
}
})();
+169
View File
@@ -0,0 +1,169 @@
(function () {
"use strict";
var toastEl = document.querySelector(".toast");
var toastTimer = null;
function toast(msg) {
if (!toastEl) return;
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.classList.remove("show");
}, 2200);
}
window.awToast = toast;
async function postJson(url, body, method) {
var res = await fetch(url, {
method: method || "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body || {}),
});
var json = null;
try {
json = await res.json();
} catch (e) {}
if (!res.ok || (json && json.ok === false)) {
throw new Error((json && json.error && json.error.message) || "请求失败(" + res.status + ")");
}
return json;
}
window.awApi = postJson;
document.querySelectorAll("[data-copy]").forEach(function (btn) {
btn.addEventListener("click", function () {
var text = btn.getAttribute("data-copy");
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(
function () { toast("已复制"); },
function () { toast("复制失败,请手动选择文本"); }
);
} else {
toast("当前环境不支持自动复制");
}
});
});
var searchInput = document.getElementById("q");
var echo = document.getElementById("query-echo");
if (searchInput && echo) {
var paint = function () {
var q = searchInput.value.trim();
echo.textContent = q
? '→ mcp: search_entries({ "q": "' + q + '", "limit": 20 })'
: '→ mcp: search_entries({ "q": "…" })';
};
searchInput.addEventListener("input", paint);
paint();
}
var searchForm = document.getElementById("search-form");
if (searchForm) {
searchForm.addEventListener("submit", function (e) {
e.preventDefault();
var q = searchForm.querySelector("#q") ? searchForm.querySelector("#q").value.trim() : "";
location.href = "/entries" + (q ? "?q=" + encodeURIComponent(q) : "");
});
}
var randomBtn = document.getElementById("random-btn");
if (randomBtn) {
randomBtn.addEventListener("click", function () {
location.href = "/random";
});
}
var navToggle = document.querySelector(".nav-toggle");
if (navToggle) {
navToggle.addEventListener("click", function () {
var bar = document.querySelector(".topbar");
var open = bar.classList.toggle("menu-open");
navToggle.setAttribute("aria-expanded", open ? "true" : "false");
});
}
var railToggle = document.getElementById("rail-toggle");
if (railToggle) {
railToggle.addEventListener("click", function () {
document.getElementById("filter-rail").classList.toggle("open");
});
}
document.querySelectorAll(".tree-row[data-tree]").forEach(function (row) {
row.addEventListener("click", function () {
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.querySelectorAll(".view-tabs button[data-view]").forEach(function (tab) {
tab.addEventListener("click", function () {
document
.querySelectorAll(".view-tabs button[data-view]")
.forEach(function (t) { t.setAttribute("aria-selected", "false"); });
tab.setAttribute("aria-selected", "true");
var view = tab.getAttribute("data-view");
document.querySelectorAll("[data-view-panel]").forEach(function (p) {
p.hidden = p.getAttribute("data-view-panel") !== view;
});
});
});
document.querySelectorAll(".filter-auto input[type=checkbox], .filter-auto select").forEach(function (el) {
el.addEventListener("change", function () {
el.form && el.form.submit();
});
});
var loginForm = document.getElementById("login-form");
if (loginForm) {
loginForm.addEventListener("submit", function (e) {
e.preventDefault();
if (!loginForm.reportValidity()) return;
postJson("/api/admin/login", {
username: loginForm.username.value,
password: loginForm.password.value,
})
.then(function () {
toast("登录成功,正在进入管理台…");
setTimeout(function () { location.href = "/admin"; }, 400);
})
.catch(function (err) { toast(err.message); });
});
}
var commentForm = document.querySelector(".comment-form[data-entry-id]");
if (commentForm) {
commentForm.addEventListener("submit", function (e) {
e.preventDefault();
if (!commentForm.reportValidity()) return;
postJson("/api/comments", {
entryId: Number(commentForm.getAttribute("data-entry-id")),
authorName: commentForm.querySelector("[name=authorName]").value,
body: commentForm.querySelector("[name=body]").value,
})
.then(function () {
toast("评论已发布");
setTimeout(function () { location.reload(); }, 500);
})
.catch(function (err) { toast(err.message); });
});
}
var feedbackForm = document.querySelector(".feedback[data-feedback]");
if (feedbackForm) {
feedbackForm.addEventListener("submit", function (e) {
e.preventDefault();
if (!feedbackForm.reportValidity()) return;
postJson("/api/feedback", { body: feedbackForm.querySelector("textarea").value })
.then(function () {
toast("已收到,感谢反馈");
feedbackForm.reset();
})
.catch(function (err) { toast(err.message); });
});
}
})();