Files
awesome/public/js/admin.js
T
cheney 806761103b
Build and Deploy / build (push) Successful in 1m32s
Build and Deploy / deploy (push) Successful in 26s
feat: 管理台搜索/分区刷新、评论人员体系、相关项目,修复内容源自动获取,完善 MCP 自描述
管理台:
- 内容管理新增名称搜索(title/slug/url/description),状态与翻页链接保留搜索词
- 菜单改为真实链接,每次点击重新向后台取数渲染分区,不再依赖手动刷新
- 内容管理每页固定 20 条
- pending 条目按钮改为「预览」,active 为「查看」

评论体系:
- 评论提交新增必填联系方式(手机号/邮箱),前端不做展示
- 首次评论以「昵称+联系方式」建档,之后需匹配且未被禁用
- 后台评论管理新增联系方式列;新增评论人员管理页,可启用/禁用/改名/删除
- 评论昵称输入框不再预置 admin

相关项目:
- 新增 entry_relations 双向关联,详情页侧栏展示并写入机读 JSON
- 编辑页可添加/移除关联

内容源自动获取:
- 修复 rss 适配器把 feed url 当成条目 url 的根本问题,支持 Atom link/href 与相对链接
- eryajf-weekly 限定正文容器取链接,修正项目说明的提取与「(opens new window)」清理
- pipeline 增加轮内去重与单条失败隔离;scheduler 对非法 cron 明确告警

MCP:
- 重写 instructions 为八节说明,新增 usage 工具返回结构化接入文档
- 补全各工具的参数、用途与返回格式说明;baseUrl 取自当次请求
- 首页 MCP 区块与 README 同步补充写入示例与踩坑清单

测试: 新增 comments/relations/admin/ingest-adapters 用例,共 29 个全部通过
2026-10-27 11:22:26 +08:00

431 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(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() {
location.reload();
}
function esc(v) {
return String(v == null ? "" : v).replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}
var SECTION_IDS = [
"sec-content",
"sec-tags",
"sec-comments",
"sec-contacts",
"sec-sources",
"sec-accounts",
];
function activateSection(id, { syncNav } = { syncNav: true }) {
if (!SECTION_IDS.includes(id)) return;
document.querySelectorAll(".admin-main > section").forEach(function (s) {
s.classList.toggle("active", s.id === id);
});
if (syncNav) {
document.querySelectorAll(".admin-nav [data-section]").forEach(function (b) {
b.setAttribute("aria-current", b.getAttribute("data-section") === id ? "true" : "false");
});
}
}
// 菜单项是真实链接:点击即向后台发起一次新的 /admin?section=… 请求,
// 服务端重新查库渲染,因此每次切分区拿到的都是最新数据(无需手动刷新)。
document.querySelectorAll(".admin-nav a[data-section]").forEach(function (link) {
link.addEventListener("click", function (e) {
// 普通左键点击交给浏览器完成整页跳转(= 重新拉数据);
// 中键 / Ctrl / Cmd 点击保留「新标签打开」的默认行为。
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
activateSection(link.getAttribute("data-section"));
document.querySelector(".admin-nav-refresh")?.setAttribute(
"href",
link.getAttribute("href")
);
});
});
// 兼容旧链接 /admin#sec-xxx:按 hash 切分区但不重新拉取
(function restoreHashSection() {
var hash = (location.hash || "").replace("#", "");
if (hash && SECTION_IDS.includes(hash)) activateSection(hash);
})();
/* ============ 标签树:客户端渲染 + 拖拽调整层级 ============ */
var tagRoot = document.getElementById("admin-tag-tree");
var tagDraggingId = null;
function tagRowHtml(n) {
var kids = n.children && n.children.length;
var label = kids
? '<button type="button" class="tree-row toggler" aria-expanded="true"><span class="tw">▶</span> <span class="name">' + esc(n.name) + "</span></button>"
: '<span class="nameleaf"><span class="name">' + esc(n.name) + "</span></span>";
return (
"<li>" +
'<div class="tree-row tag-node" draggable="true" data-id="' + n.id + '" data-drop-id="' + n.id + '">' +
label +
'<span class="mono cnt">' + n.count + " · #" + n.id + "</span>" +
'<span class="row-actions" style="margin-left:8px">' +
'<button class="btn btn-sm" type="button" data-action="tag-add" data-parent-id="' + n.id + '">+子级</button>' +
'<button class="btn btn-sm" type="button" data-action="tag-rename" data-id="' + n.id + '" data-name="' + esc(n.name) + '">改名</button>' +
'<button class="btn btn-sm" type="button" data-action="tag-move" data-id="' + n.id + '">移动</button>' +
'<button class="btn btn-sm btn-danger" type="button" data-action="tag-delete" data-id="' + n.id + '">删</button>' +
"</span>" +
"</div>" +
(kids ? '<ul class="tag-children open">' + n.children.map(tagRowHtml).join("") + "</ul>" : "") +
"</li>"
);
}
function renderTagAdminTree(tree) {
if (!tagRoot) return;
tagRoot.innerHTML = tree && tree.length
? '<ul class="tag-tree">' + tree.map(tagRowHtml).join("") + "</ul>"
: '<p class="mono dim">还没有标签。</p>';
}
async function refreshTags() {
if (!tagRoot) return;
var json = await api("/api/tags/tree", undefined, "GET");
renderTagAdminTree(json.data);
}
function clearTagDnd() {
tagDraggingId = null;
if (!tagRoot) return;
tagRoot.classList.remove("drag-over");
tagRoot.querySelectorAll(".drag-src,.drag-over").forEach(function (el) {
el.classList.remove("drag-src", "drag-over");
});
}
if (tagRoot) {
if (Array.isArray(window.__ADMIN_TAG_TREE__)) renderTagAdminTree(window.__ADMIN_TAG_TREE__);
else refreshTags();
tagRoot.addEventListener("click", function (e) {
var tog = e.target.closest(".toggler");
if (!tog) return;
var li = tog.closest("li");
var ul = null;
if (li) {
for (var i = 0; i < li.children.length; i++) {
if (li.children[i].tagName === "UL") ul = li.children[i];
}
}
if (!ul) return;
var open = ul.classList.toggle("open");
tog.setAttribute("aria-expanded", open ? "true" : "false");
});
tagRoot.addEventListener("dragstart", function (e) {
if (e.target.closest("button")) return;
var row = e.target.closest(".tag-node");
if (!row) return;
tagDraggingId = row.getAttribute("data-id");
e.dataTransfer.setData("text/plain", tagDraggingId);
e.dataTransfer.effectAllowed = "move";
row.classList.add("drag-src");
});
tagRoot.addEventListener("dragend", clearTagDnd);
tagRoot.addEventListener("dragover", function (e) {
if (!tagDraggingId) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
var row = e.target.closest(".tag-node[data-drop-id]");
tagRoot.querySelectorAll(".drag-over").forEach(function (el) {
el.classList.remove("drag-over");
});
if (row) row.classList.add("drag-over");
else tagRoot.classList.add("drag-over");
});
tagRoot.addEventListener("dragleave", function (e) {
if (e.target === tagRoot && !e.relatedTarget) tagRoot.classList.remove("drag-over");
});
tagRoot.addEventListener("drop", async function (e) {
var id = tagDraggingId;
var row = e.target.closest(".tag-node[data-drop-id]");
var dropTarget = row ? row.getAttribute("data-drop-id") : null;
clearTagDnd();
if (!id || dropTarget === id) return;
try {
await api("/api/tags/" + id, { parentId: dropTarget ? Number(dropTarget) : null }, "PATCH");
toast(dropTarget ? "已移动到目标标签下" : "已移回顶层");
await refreshTags();
} catch (err) {
toast(err.message);
}
});
}
/* ============ 批量选择 / 批量状态 ============ */
function selectedEntryIds() {
var ids = [];
document.querySelectorAll(".entry-check:checked").forEach(function (c) {
ids.push(Number(c.getAttribute("data-id")));
});
return ids;
}
function updateBatchCount() {
var el = document.getElementById("batch-count");
if (el) el.textContent = "已选 " + selectedEntryIds().length + " 项";
}
document.addEventListener("change", function (e) {
var ck = e.target;
if (ck.classList && ck.classList.contains("entry-check-all")) {
document.querySelectorAll(".entry-check").forEach(function (c) {
c.checked = ck.checked;
});
updateBatchCount();
} else if (ck.classList && ck.classList.contains("entry-check")) {
updateBatchCount();
}
});
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 });
await refreshTags();
toast("标签已创建");
}
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");
await refreshTags();
toast("已重命名");
}
if (action === "tag-move") {
var target3 = prompt("移动到哪个标签之下?填其 ID(留空移到顶层):");
if (target3 === null) return;
await api("/api/tags/" + btn.getAttribute("data-id"),
{ parentId: target3.trim() ? Number(target3.trim()) : null }, "PATCH");
await refreshTags();
toast("已移动");
}
if (action === "tag-delete") {
if (!confirm("删除该标签?其子标签将上移一级,关联关系解除。")) return;
await api("/api/tags/" + btn.getAttribute("data-id"), undefined, "DELETE");
await refreshTags();
toast("已删除");
}
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 === "entry-select-all") {
var checks = Array.prototype.slice.call(document.querySelectorAll(".entry-check"));
var allOn = checks.length > 0 && checks.every(function (c) { return c.checked; });
checks.forEach(function (c) { c.checked = !allOn; });
var allEl = document.querySelector(".entry-check-all");
if (allEl) allEl.checked = !allOn;
updateBatchCount();
}
if (action === "entry-batch") {
var ids = selectedEntryIds();
if (!ids.length) { toast("请先勾选条目"); return; }
var status = btn.getAttribute("data-status");
await api("/api/admin/entries/status", { ids: ids, status: status }, "POST");
toast(status === "active" ? "已上架 " + ids.length + " 条" : "已灰掉 " + ids.length + " 条");
refresh();
}
if (action === "comment-delete") {
if (!confirm("删除该评论?")) return;
await api("/api/comments/" + btn.getAttribute("data-id"), undefined, "DELETE");
toast("已删除"); refresh();
}
if (action === "contact-status") {
var enabled = btn.getAttribute("data-enabled") === "1";
if (enabled && !confirm("禁用后该联系方式将无法继续评论,确认?")) return;
await api("/api/admin/contacts/" + btn.getAttribute("data-id") + "/status",
{ enabled: !enabled }, "PATCH");
toast(enabled ? "已禁用" : "已启用"); refresh();
}
if (action === "contact-rename") {
var nick = prompt("修改昵称:", btn.getAttribute("data-name"));
if (!nick || nick === btn.getAttribute("data-name")) return;
await api("/api/admin/contacts/" + btn.getAttribute("data-id"),
{ nickname: nick }, "PATCH");
toast("昵称已更新"); refresh();
}
if (action === "contact-delete") {
if (!confirm("删除该评论人员?其历史评论会保留。")) return;
await api("/api/admin/contacts/" + btn.getAttribute("data-id"), undefined, "DELETE");
toast("已删除"); refresh();
}
if (action === "relation-add") {
var sel = document.getElementById("f-related");
if (!sel || !sel.value) { toast("没有可关联的条目"); return; }
var opt = sel.options[sel.selectedIndex];
await api("/api/admin/entries/" + window.__ADMIN_ENTRY_ID__ + "/relations",
{ relatedId: Number(sel.value) }, "POST");
toast("已关联:" + opt.textContent.trim().split(" · ")[0]);
refresh();
}
if (action === "relation-remove") {
if (!confirm("移除该关联?")) return;
await api("/api/admin/entries/" + window.__ADMIN_ENTRY_ID__ +
"/relations/" + btn.getAttribute("data-related-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;
if (s.error) {
toast("拉取失败:" + s.error);
} else {
toast("完成:发现 " + s.found + " · 新增 " + s.created + " · 跳过 " + s.skipped);
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); });
});
}
})();