管理台: - 内容管理新增名称搜索(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 个全部通过
431 lines
16 KiB
JavaScript
431 lines
16 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() {
|
||
location.reload();
|
||
}
|
||
|
||
function esc(v) {
|
||
return String(v == null ? "" : v).replace(/[&<>"']/g, function (c) {
|
||
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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); });
|
||
});
|
||
}
|
||
})(); |