学生管理系统代码
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
/* 全页面悬浮问答:复用后端 AI 接口,不在前端保存密钥。 */
|
||||
"use strict";
|
||||
(() => {
|
||||
const byId = id => document.getElementById(id);
|
||||
const trigger = byId("floating-ai-trigger");
|
||||
const panel = byId("floating-ai-panel");
|
||||
const input = byId("floating-ai-question");
|
||||
const messages = byId("floating-ai-messages");
|
||||
let busy = false;
|
||||
// 超过 6 像素才视为拖动,轻点仍打开聊天;鼠标和触屏共用 Pointer Events。
|
||||
let drag = null;
|
||||
let suppressClick = false;
|
||||
const margin = 12;
|
||||
const clamp = (value, min, max) => Math.min(Math.max(value, min), Math.max(min, max));
|
||||
function viewport() {
|
||||
const view = window.visualViewport;
|
||||
return {left: view ? view.offsetLeft : 0, top: view ? view.offsetTop : 0,
|
||||
width: view ? view.width : document.documentElement.clientWidth,
|
||||
height: view ? view.height : window.innerHeight};
|
||||
}
|
||||
function placeTrigger(left, top) {
|
||||
const view = viewport(), box = trigger.getBoundingClientRect();
|
||||
trigger.style.right = "auto"; trigger.style.bottom = "auto";
|
||||
trigger.style.left = `${clamp(left, view.left + margin, view.left + view.width - box.width - margin)}px`;
|
||||
trigger.style.top = `${clamp(top, view.top + margin, view.top + view.height - box.height - margin)}px`;
|
||||
placePanel();
|
||||
}
|
||||
function placePanel() {
|
||||
if (panel.hidden) return;
|
||||
const view = viewport(), anchor = trigger.getBoundingClientRect();
|
||||
const leftEdge = view.left + margin, topEdge = view.top + margin;
|
||||
const rightEdge = view.left + view.width - margin, bottomEdge = view.top + view.height - margin;
|
||||
const gap = 10;
|
||||
panel.style.right = "auto"; panel.style.bottom = "auto";
|
||||
panel.style.maxWidth = `${Math.max(0, view.width - margin * 2)}px`;
|
||||
const width = panel.getBoundingClientRect().width;
|
||||
let left, top;
|
||||
// 宽屏优先放在头像旁边,窄屏在头像上方或下方展开。
|
||||
if (anchor.left - gap - width >= leftEdge || anchor.right + gap + width <= rightEdge) {
|
||||
left = anchor.left - gap - width >= leftEdge ? anchor.left - gap - width : anchor.right + gap;
|
||||
panel.style.maxHeight = `${Math.max(0, view.height - margin * 2)}px`;
|
||||
top = clamp(anchor.bottom - panel.getBoundingClientRect().height, topEdge, bottomEdge - panel.getBoundingClientRect().height);
|
||||
} else {
|
||||
const above = anchor.top - gap - topEdge, below = bottomEdge - anchor.bottom - gap;
|
||||
const useAbove = above >= below;
|
||||
panel.style.maxHeight = `${Math.max(0, useAbove ? above : below)}px`;
|
||||
top = useAbove ? anchor.top - gap - panel.getBoundingClientRect().height : anchor.bottom + gap;
|
||||
left = clamp(anchor.right - width, leftEdge, rightEdge - width);
|
||||
}
|
||||
panel.style.left = `${left}px`; panel.style.top = `${top}px`;
|
||||
}
|
||||
trigger.addEventListener("pointerdown", event => {
|
||||
if (!event.isPrimary || event.button !== 0) return;
|
||||
const box = trigger.getBoundingClientRect();
|
||||
suppressClick = false;
|
||||
drag = {id: event.pointerId, x: event.clientX, y: event.clientY, left: box.left, top: box.top, moved: false};
|
||||
trigger.setPointerCapture(event.pointerId);
|
||||
});
|
||||
trigger.addEventListener("pointermove", event => {
|
||||
if (!drag || drag.id !== event.pointerId) return;
|
||||
const dx = event.clientX - drag.x, dy = event.clientY - drag.y;
|
||||
if (!drag.moved && Math.hypot(dx, dy) < 6) return;
|
||||
drag.moved = true;
|
||||
trigger.classList.add("dragging");
|
||||
placeTrigger(drag.left + dx, drag.top + dy);
|
||||
});
|
||||
function endDrag(event) {
|
||||
if (!drag || drag.id !== event.pointerId) return;
|
||||
suppressClick = drag.moved;
|
||||
drag = null;
|
||||
trigger.classList.remove("dragging");
|
||||
if (trigger.hasPointerCapture(event.pointerId)) trigger.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
trigger.addEventListener("pointerup", endDrag);
|
||||
trigger.addEventListener("pointercancel", endDrag);
|
||||
trigger.addEventListener("lostpointercapture", endDrag);
|
||||
trigger.addEventListener("dragstart", event => event.preventDefault());
|
||||
function fitViewport() {
|
||||
const box = trigger.getBoundingClientRect();
|
||||
placeTrigger(box.left, box.top);
|
||||
}
|
||||
window.addEventListener("resize", fitViewport);
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.addEventListener("resize", fitViewport);
|
||||
window.visualViewport.addEventListener("scroll", fitViewport);
|
||||
}
|
||||
// 回答变长、输入框调整高度时,聊天框也跟随头像保持在可视区域。
|
||||
if (typeof ResizeObserver !== "undefined") new ResizeObserver(placePanel).observe(panel);
|
||||
|
||||
function setOpen(open, returnFocus = true) {
|
||||
panel.hidden = !open;
|
||||
if (open) placePanel();
|
||||
trigger.setAttribute("aria-expanded", String(open));
|
||||
if (open) {if (!busy) input.focus();} else if (returnFocus) trigger.focus();
|
||||
}
|
||||
function scrollBottom() {const area = messages.parentElement; area.scrollTop = area.scrollHeight;}
|
||||
function appendMessage(role, text, failed = false) {
|
||||
const article = document.createElement("article"); article.className = `floating-message ${role}${failed ? " failed" : ""}`;
|
||||
const label = document.createElement("span"); label.className = "floating-message-label"; label.textContent = role === "user" ? "你" : "沃林 AI 助手";
|
||||
const body = document.createElement("div"); body.className = "floating-message-body";
|
||||
if (role === "assistant" && !failed && typeof answerMarkup === "function") body.innerHTML = answerMarkup(text); else body.textContent = text;
|
||||
article.append(label, body); messages.append(article); scrollBottom(); return body;
|
||||
}
|
||||
function setBusy(value) {
|
||||
busy = value; input.readOnly = value;
|
||||
byId("floating-ai-send").disabled = value;
|
||||
byId("floating-ai-clear").disabled = value;
|
||||
byId("floating-ai-send").textContent = value ? "回答中…" : "发送 ↗";
|
||||
byId("floating-ai-progress").hidden = !value;
|
||||
trigger.classList.toggle("thinking", value);
|
||||
}
|
||||
async function submitQuestion() {
|
||||
if (busy) return;
|
||||
const question = input.value.trim();
|
||||
const error = byId("floating-ai-error"); error.hidden = true;
|
||||
if (!question || question.length > 1800) {error.textContent = "请输入 1 至 1800 个字符的问题。"; error.hidden = false; return;}
|
||||
byId("floating-ai-greeting").hidden = true;
|
||||
appendMessage("user", question); setBusy(true);
|
||||
try {
|
||||
const response = await fetch("/api/studentsManagement/ai/chat", {
|
||||
method:"POST", headers:{"Content-Type":"application/json"},
|
||||
body:JSON.stringify({question:question + "\n请简短回答,优先给出结论和必要数据,尽量控制在150字内;如果数据不足请说明,不要省略影响结论的条件。"}),
|
||||
signal:AbortSignal.timeout(150000),
|
||||
});
|
||||
let result;
|
||||
try {result = await response.json();} catch {throw new Error("AI 服务没有返回有效回答,请稍后重试。");}
|
||||
if (!response.ok) throw new Error(typeof result.detail === "string" ? result.detail : "AI 服务暂时不可用,请稍后重试。");
|
||||
if (typeof result.answer !== "string" || !result.answer.trim()) throw new Error("AI 返回了空回答,请稍后重试。");
|
||||
const body = appendMessage("assistant", result.answer);
|
||||
const sources = document.createElement("small"); sources.className = "floating-sources";
|
||||
sources.textContent = result.data ? "依据:本次数据库查询与需求资料" : "依据:本次参考资料 · 未查询数据库";
|
||||
body.append(sources);
|
||||
input.value = ""; byId("floating-ai-count").textContent = "0 / 1800";
|
||||
} catch (reason) {
|
||||
const message = reason.name === "TimeoutError" ? "等待回答超时,请稍后重试。" : reason instanceof TypeError ? "无法连接服务,请确认后端已启动。" : reason.message || "请求失败,请稍后重试。";
|
||||
appendMessage("assistant", message, true);
|
||||
error.textContent = "原问题已保留,可以重试。"; error.hidden = false;
|
||||
} finally {setBusy(false); scrollBottom(); if (!panel.hidden && panel.contains(document.activeElement)) input.focus();}
|
||||
}
|
||||
trigger.addEventListener("click", event => {
|
||||
if (suppressClick && event.detail !== 0) {suppressClick = false; return;}
|
||||
suppressClick = false;
|
||||
setOpen(panel.hidden);
|
||||
});
|
||||
byId("floating-ai-close").addEventListener("click", () => setOpen(false));
|
||||
panel.addEventListener("keydown", event => {if (event.key === "Escape") {event.preventDefault(); setOpen(false);}});
|
||||
byId("floating-ai-form").addEventListener("submit", event => {event.preventDefault(); submitQuestion();});
|
||||
input.addEventListener("input", () => byId("floating-ai-count").textContent = `${input.value.length} / 1800`);
|
||||
input.addEventListener("keydown", event => {if (event.key === "Enter" && !event.shiftKey && !event.ctrlKey && !event.metaKey && !event.isComposing) {event.preventDefault(); submitQuestion();}});
|
||||
byId("floating-ai-clear").addEventListener("click", () => {
|
||||
if (busy) return;
|
||||
messages.replaceChildren(); byId("floating-ai-greeting").hidden = false;
|
||||
byId("floating-ai-error").hidden = true; input.value = ""; byId("floating-ai-count").textContent = "0 / 1800"; input.focus();
|
||||
});
|
||||
byId("floating-ai-full").addEventListener("click", () => {
|
||||
// 跳转到现有完整助手,不重复发送,也不扩大数据库操作权限。
|
||||
if (input.value.trim()) {byId("ai-question").value = input.value.trim(); byId("ai-length").textContent = `${input.value.trim().length} / 2000`;}
|
||||
location.hash = "ai"; setOpen(false, false); byId("ai-question").focus();
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user