v1.1.0 管理后台+对话增强:/admin全数据CRUD与站点配置;快捷语句点击自动提交;Markdown回答渲染;参考资讯折叠链接;实体识别标记与快速查看卡片(entity_linker)

This commit is contained in:
2026-08-17 13:19:36 +08:00
parent e11677809e
commit fcf5d3fee4
13 changed files with 1299 additions and 43 deletions
+99 -7
View File
@@ -6,6 +6,39 @@ const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "
const chatHistory = [];
let chatBusy = false;
/* ================= Markdown 渲染(marked 本地库,先转义防 XSS ================= */
function md(text) {
if (window.marked) {
marked.setOptions({ breaks: true, gfm: true });
return marked.parse(esc(text));
}
return esc(text).replace(/\n/g, "<br>");
}
/* 实体高亮:先用占位符替换实体区间(避免 markdown 转换后偏移错位),渲染后再恢复为可点击标签 */
function renderMdWithEntities(reply, entities) {
let text = reply || "";
const phs = [];
if (entities && entities.length) {
const sorted = [...entities].sort((a, b) => b.start - a.start);
for (const sp of sorted) {
if (sp.start < 0 || sp.end > text.length || sp.start >= sp.end) continue;
const ph = `\u27E6E${phs.length}\u27E7`;
phs.push(sp);
text = text.slice(0, sp.start) + ph + text.slice(sp.end);
}
}
let html = md(text);
phs.forEach((sp, i) => {
const ph = `\u27E6E${i}\u27E7`;
if (html.includes(ph)) {
const tag = `<span class="entity entity-${sp.type}" data-type="${sp.type}" data-id="${sp.id}" data-name="${esc(sp.name)}">${esc(sp.name)}</span>`;
html = html.split(ph).join(tag);
}
});
return html;
}
/* ================= 标签页切换 ================= */
$$(".tab").forEach((t) => t.addEventListener("click", () => {
$$(".tab").forEach((x) => x.classList.remove("active"));
@@ -41,6 +74,19 @@ function showTyping() {
$("#chat-list").scrollTop = $("#chat-list").scrollHeight;
return div;
}
/* 实体快速查看卡片 HTML */
function cardHtml(c) {
if (c.type === "team") return `<div class="ecard" data-type="team" data-id="${c.id}"><span class="eico">🏟️</span><div><b>${esc(c.name)}</b><small>${esc(c.city || "")} · 总冠军×${c.champion_count ?? "?"}</small></div></div>`;
if (c.type === "player") return `<div class="ecard" data-type="player" data-id="${c.id}"><span class="eico">⭐</span><div><b>${esc(c.name)}</b><small>${esc(c.team || "自由球员")}${c.season ? " · " + (c.season.pts ?? "?") + "分" : ""}</small></div></div>`;
if (c.type === "person") return `<div class="ecard" data-type="person" data-id="${c.id}"><span class="eico">👤</span><div><b>${esc(c.name)}</b><small>${esc(c.role_cn || "")}</small></div></div>`;
if (c.type === "game") {
const score = c.status === "finished" ? `${c.away_score ?? "?"} : ${c.home_score ?? "?"}` : "VS";
return `<div class="ecard" data-type="game" data-id="${c.id}"><span class="eico">🏀</span><div><b>${esc(c.away_team || "")} ${score} ${esc(c.home_team || "")}</b><small>${esc(c.round_name || "")} · ${esc((c.game_time || "").slice(0, 16))}</small></div></div>`;
}
return "";
}
async function sendChat(text) {
if (chatBusy) return;
chatBusy = true;
@@ -53,9 +99,22 @@ async function sendChat(text) {
const d = await r.json();
typing.remove();
if (d.error) { addMsg("bot", `⚠️ ${esc(d.error)}`); return; }
let html = esc(d.reply).replace(/\n/g, "<br>");
let html = "";
// 需求4:参考资讯(新闻/百科链接),默认折叠,位于回答块上方
if (d.news_refs && d.news_refs.length) {
html += `<details class="refs"><summary>📰 参考资讯(${d.news_refs.length}</summary><ul>` +
d.news_refs.map((n) => `<li><a href="#" data-news="${n.id}">${esc(n.title)}</a>` +
(n.source ? `<span class="ref-src">${esc(n.source)}${n.publish_time ? " · " + esc(n.publish_time.slice(0, 10)) : ""}</span>` : "") + `</li>`).join("") +
`</ul></details>`;
}
// 需求3markdown 渲染 + 需求5:实体特殊标记
html += `<div class="md">${renderMdWithEntities(d.reply, d.entities)}</div>`;
// 需求5:快速查看入口卡片
if (d.cards && d.cards.length) {
html += `<div class="entity-cards">${d.cards.map(cardHtml).join("")}</div>`;
}
if (d.sources && d.sources.length) {
html += "<br>" + d.sources.map((s) => `<span class="src-tag">来源:${esc(s.tool)}</span>`).join("");
html += "<div class='src-line'>" + d.sources.map((s) => `<span class="src-tag">来源:${esc(s.tool)}</span>`).join("") + "</div>";
}
addMsg("bot", html);
chatHistory.push({ user: text, assistant: d.reply });
@@ -71,15 +130,48 @@ async function sendChat(text) {
$("#send-btn").addEventListener("click", () => { const v = $("#chat-input").value.trim(); if (v) { $("#chat-input").value = ""; sendChat(v); } });
$("#chat-input").addEventListener("keydown", (e) => { if (e.key === "Enter") $("#send-btn").click(); });
/* 快捷问题 */
async function loadChips() {
/* 快捷问题:点击 → 自动填入输入框并自动提交(需求2) */
function askQuick(q) {
$("#chat-input").value = q;
sendChat(q);
}
/* 聊天区事件委托:快捷语句 / 实体标记 / 新闻链接 / 卡片 */
$("#chat-list").addEventListener("click", (e) => {
const q = e.target.closest(".quick-q");
if (q) { e.preventDefault(); askQuick(q.textContent); return; }
const ent = e.target.closest(".entity, .ecard");
if (ent && ent.dataset.type) { openEntity(ent.dataset.type, parseInt(ent.dataset.id)); return; }
const news = e.target.closest("a[data-news]");
if (news) { e.preventDefault(); openNews(parseInt(news.dataset.news)); return; }
});
function openEntity(type, id) {
try {
const qs = await (await fetch("/api/suggestions")).json();
if (type === "team") openTeam(id);
else if (type === "player") openPlayer(id);
else if (type === "person") openPerson(id);
else if (type === "game") openGame(id);
} catch (e) {
openModal(`<h2>⚠️ 打开失败</h2><p style="color:var(--sub)">该${type}记录可能已被删除(ID=${id})。</p>`);
}
}
/* 启动信息:开场白 + 快捷问题(从配置加载,管理后台可编辑) */
async function loadBoot() {
try {
const b = await (await fetch("/api/boot")).json();
if (b.welcome_text) $("#welcome-text").innerHTML = md(b.welcome_text);
const qs = b.suggestions || [];
const hint = (b.welcome_hint || "试试:") + " ";
$("#welcome-hint").innerHTML = hint + qs.map((q) => `<a class="quick-q" href="#">${esc(q)}</a>`).join(" / ");
$("#chips").innerHTML = qs.map((q) => `<button>${esc(q)}</button>`).join("");
$$("#chips button").forEach((b) => b.addEventListener("click", () => sendChat(b.textContent)));
$$("#chips button").forEach((b2) => b2.addEventListener("click", () => askQuick(b2.textContent)));
if (b.site_name) document.title = b.site_name;
if (b.footer_text) $("#footer-text").textContent = b.footer_text;
} catch (e) {}
}
loadChips();
loadBoot();
/* ================= 通用请求 ================= */
async function getJSON(url) { const r = await fetch(url); if (!r.ok) throw new Error(r.status); return r.json(); }