v1.1.0 管理后台+对话增强:/admin全数据CRUD与站点配置;快捷语句点击自动提交;Markdown回答渲染;参考资讯折叠链接;实体识别标记与快速查看卡片(entity_linker)
This commit is contained in:
+99
-7
@@ -6,6 +6,39 @@ const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "
|
||||
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>`;
|
||||
}
|
||||
// 需求3:markdown 渲染 + 需求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(); }
|
||||
|
||||
Reference in New Issue
Block a user