feat: 管理台搜索/分区刷新、评论人员体系、相关项目,修复内容源自动获取,完善 MCP 自描述
Build and Deploy / build (push) Successful in 1m32s
Build and Deploy / deploy (push) Successful in 26s

管理台:
- 内容管理新增名称搜索(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 个全部通过
This commit is contained in:
cheney
2026-10-27 11:22:26 +08:00
parent 3f7b48325f
commit 806761103b
29 changed files with 1727 additions and 133 deletions
+121
View File
@@ -732,6 +732,20 @@ button.chip:hover {
.step pre .s {
color: #ffd9a0;
}
.step pre .c {
color: #7d86a8;
font-style: italic;
}
.tools-table td:nth-child(2) {
font-family: var(--font-mono);
font-size: 11.5px;
color: var(--dim-on-panel);
white-space: nowrap;
}
.tools-table td:last-child {
color: var(--dim-on-panel);
line-height: 1.6;
}
.tools-table {
width: 100%;
border-collapse: collapse;
@@ -2035,6 +2049,113 @@ button.chip:hover {
}
}
.mcp-pitfalls {
margin-top: 30px;
padding: 20px 22px;
border: var(--border);
border-radius: 12px;
background: var(--paper);
}
.mcp-pitfalls ol {
margin: 0;
padding-left: 20px;
display: grid;
gap: 9px;
}
.mcp-pitfalls li {
font-size: 13.5px;
line-height: 1.7;
color: var(--ink-dim);
}
.mcp-pitfalls li b {
color: var(--ink);
}
.mcp-pitfalls .mono {
font-size: 12.5px;
}
/* ---------- 管理台:分区导航 / 搜索 / 评论人员 / 相关项目 ---------- */
.admin-nav a[data-section] {
display: flex;
align-items: center;
gap: 10px;
border-radius: 8px;
padding: 10px 12px;
font-size: 14px;
font-weight: 600;
color: inherit;
text-decoration: none;
}
.admin-nav a[data-section][aria-current="true"] {
background: var(--ink);
color: var(--paper);
}
.admin-nav .admin-nav-refresh {
margin-top: auto;
padding: 9px 12px;
border-radius: 8px;
border: var(--border);
font-family: var(--font-mono);
font-size: 12px;
color: var(--ink-dim);
text-decoration: none;
text-align: center;
}
.admin-nav .admin-nav-refresh:hover {
color: var(--ink);
border-color: var(--ink);
}
.admin-search {
display: flex;
gap: 8px;
align-items: center;
margin-top: 14px;
flex-wrap: wrap;
}
.admin-search .input {
flex: 1;
min-width: 240px;
}
.related-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 10px;
}
.related-list li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 9px 0;
border-bottom: 1px dashed var(--ink-faint);
flex-wrap: wrap;
}
.related-list li:last-child {
border-bottom: 0;
}
.related-list li > a {
display: flex;
align-items: center;
gap: 8px;
color: inherit;
text-decoration: none;
min-width: 0;
}
.related-list li > a:hover b {
color: var(--riso-blue);
}
.related-list .chip-type {
flex: none;
}
aside .related-list li {
display: block;
padding: 10px 0;
}
/* ---------- 降低动效 ---------- */
@media (prefers-reduced-motion: reduce) {
html {
+83 -13
View File
@@ -35,19 +35,48 @@
});
}
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);
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;
@@ -260,6 +289,45 @@
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");
@@ -317,10 +385,12 @@
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();
if (s.error) {
toast("拉取失败:" + s.error);
} else {
toast("完成:发现 " + s.found + " · 新增 " + s.created + " · 跳过 " + s.skipped);
refresh();
}
} catch (err) {
toast(err.message);
} finally {
+25
View File
@@ -150,14 +150,39 @@
});
}
var CONTACT_EMAIL = /^[^\s@]+@[^\s@]+\.[A-Za-z]{2,}$/;
var CONTACT_PHONE = /^1[3-9]\d{9}$/;
function validContact(v) {
var s = String(v == null ? "" : v).trim();
return CONTACT_EMAIL.test(s) || CONTACT_PHONE.test(s);
}
var commentForm = document.querySelector(".comment-form[data-entry-id]");
if (commentForm) {
var contactInput = commentForm.querySelector("[name=contact]");
if (contactInput) {
contactInput.addEventListener("input", function () {
if (contactInput.value.trim() && validContact(contactInput.value)) {
contactInput.setCustomValidity("");
} else {
contactInput.setCustomValidity("请填写正确的手机号或邮箱(不会公开显示)");
}
});
}
commentForm.addEventListener("submit", function (e) {
e.preventDefault();
var contact = contactInput ? contactInput.value.trim() : "";
if (!validContact(contact)) {
toast("请填写正确的联系方式:手机号或邮箱");
if (contactInput) contactInput.focus();
return;
}
if (!commentForm.reportValidity()) return;
postJson("/api/comments", {
entryId: Number(commentForm.getAttribute("data-entry-id")),
authorName: commentForm.querySelector("[name=authorName]").value,
contact: contact,
body: commentForm.querySelector("[name=body]").value,
})
.then(function () {