From 636e1ff1ccb268a37c3f64a65d1a5fd25ff3db2a Mon Sep 17 00:00:00 2001
From: Youzini-afk <13153778771cx@gmail.com>
Date: Fri, 27 Mar 2026 12:53:16 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E6=82=AC=E6=B5=AE=E7=90=83-glassmorphi?=
=?UTF-8?q?sm+=E6=8B=96=E6=8B=BD+=E5=8D=95=E5=87=BB=E5=BC=80=E9=9D=A2?=
=?UTF-8?q?=E6=9D=BF+=E5=8F=8C=E5=87=BB=E9=87=8DRoll+=E7=8A=B6=E6=80=81?=
=?UTF-8?q?=E5=90=8C=E6=AD=A5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
index.js | 3 ++
panel.js | 153 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
style.css | 101 +++++++++++++++++++++++++++++++++++
3 files changed, 257 insertions(+)
diff --git a/index.js b/index.js
index 1958943..eefb3cd 100644
--- a/index.js
+++ b/index.js
@@ -1076,6 +1076,9 @@ function notifyStatusToast(key, kind, message, title = "ST-BME") {
function setRuntimeStatus(text, meta, level = "info") {
runtimeStatus = createUiStatus(text, meta, level);
refreshPanelLiveState();
+ // 同步悬浮球状态
+ const fabStatus = level === "info" ? "idle" : level;
+ _panelModule?.updateFloatingBallStatus?.(fabStatus, text || "BME 记忆图谱");
}
function setLastExtractionStatus(
diff --git a/panel.js b/panel.js
index 7c96ed5..41174fe 100644
--- a/panel.js
+++ b/panel.js
@@ -222,6 +222,159 @@ export async function initPanel({
_applyWorkspaceMode();
_syncConfigSectionState();
_refreshRuntimeStatus();
+ _initFloatingBall();
+}
+
+// ==================== 悬浮球 ====================
+
+const FAB_STORAGE_KEY = "bme-fab-position";
+let _fabEl = null;
+
+function _initFloatingBall() {
+ if (document.getElementById("bme-floating-ball")) return;
+
+ const fab = document.createElement("div");
+ fab.id = "bme-floating-ball";
+ fab.setAttribute("data-status", "idle");
+ fab.innerHTML = `
+
+ BME 记忆图谱
+ `;
+ document.body.appendChild(fab);
+ _fabEl = fab;
+
+ // 恢复位置
+ const saved = _loadFabPosition();
+ if (saved) {
+ fab.style.left = `${saved.x}px`;
+ fab.style.top = `${saved.y}px`;
+ fab.style.right = "auto";
+ fab.style.bottom = "auto";
+ } else {
+ fab.style.right = "16px";
+ fab.style.bottom = "80px";
+ }
+
+ // 拖拽 + 点击逻辑
+ let isDragging = false;
+ let hasMoved = false;
+ let startX = 0, startY = 0;
+ let fabStartX = 0, fabStartY = 0;
+ let clickTimer = null;
+
+ const DRAG_THRESHOLD = 5;
+ const DBLCLICK_DELAY = 280;
+
+ function onPointerDown(e) {
+ isDragging = true;
+ hasMoved = false;
+ startX = e.clientX;
+ startY = e.clientY;
+ const rect = fab.getBoundingClientRect();
+ fabStartX = rect.left;
+ fabStartY = rect.top;
+ fab.setPointerCapture(e.pointerId);
+ e.preventDefault();
+ }
+
+ function onPointerMove(e) {
+ if (!isDragging) return;
+ const dx = e.clientX - startX;
+ const dy = e.clientY - startY;
+ if (!hasMoved && Math.abs(dx) < DRAG_THRESHOLD && Math.abs(dy) < DRAG_THRESHOLD) return;
+ hasMoved = true;
+
+ let newX = fabStartX + dx;
+ let newY = fabStartY + dy;
+ // 限制在视口内
+ const size = 46;
+ newX = Math.max(0, Math.min(window.innerWidth - size, newX));
+ newY = Math.max(0, Math.min(window.innerHeight - size, newY));
+
+ fab.style.left = `${newX}px`;
+ fab.style.top = `${newY}px`;
+ fab.style.right = "auto";
+ fab.style.bottom = "auto";
+ }
+
+ function onPointerUp(e) {
+ if (!isDragging) return;
+ isDragging = false;
+ fab.releasePointerCapture(e.pointerId);
+
+ if (hasMoved) {
+ // 拖拽结束 → 保存位置
+ _saveFabPosition(parseInt(fab.style.left), parseInt(fab.style.top));
+ return;
+ }
+
+ // 非拖拽 → 处理单击/双击
+ if (clickTimer) {
+ // 第二次点击 → 双击 → 重 Roll
+ clearTimeout(clickTimer);
+ clickTimer = null;
+ _onFabDoubleClick();
+ } else {
+ // 第一次点击 → 等待双击
+ clickTimer = setTimeout(() => {
+ clickTimer = null;
+ _onFabSingleClick();
+ }, DBLCLICK_DELAY);
+ }
+ }
+
+ fab.addEventListener("pointerdown", onPointerDown);
+ document.addEventListener("pointermove", onPointerMove);
+ document.addEventListener("pointerup", onPointerUp);
+}
+
+function _onFabSingleClick() {
+ openPanel();
+}
+
+async function _onFabDoubleClick() {
+ if (!_actionHandlers.reroll) return;
+ if (!confirm("确认重新提取最新 AI 楼?")) return;
+
+ try {
+ _fabEl?.setAttribute("data-status", "running");
+ await _actionHandlers.reroll({});
+ _fabEl?.setAttribute("data-status", "success");
+ _refreshDashboard();
+ _refreshGraph();
+ setTimeout(() => {
+ const status = _getRuntimeStatus?.() || {};
+ _fabEl?.setAttribute("data-status", status.status || "idle");
+ }, 3000);
+ } catch (err) {
+ console.error("[ST-BME] FAB reroll failed:", err);
+ _fabEl?.setAttribute("data-status", "error");
+ }
+}
+
+function _loadFabPosition() {
+ try {
+ const raw = localStorage.getItem(FAB_STORAGE_KEY);
+ if (!raw) return null;
+ const pos = JSON.parse(raw);
+ if (Number.isFinite(pos.x) && Number.isFinite(pos.y)) return pos;
+ } catch {}
+ return null;
+}
+
+function _saveFabPosition(x, y) {
+ try {
+ localStorage.setItem(FAB_STORAGE_KEY, JSON.stringify({ x, y }));
+ } catch {}
+}
+
+export function updateFloatingBallStatus(status = "idle", tooltipText = "") {
+ if (!_fabEl) return;
+ _fabEl.setAttribute("data-status", status);
+ if (tooltipText) {
+ const tip = _fabEl.querySelector(".bme-fab-tooltip");
+ if (tip) tip.textContent = tooltipText;
+ }
}
/**
diff --git a/style.css b/style.css
index c84840d..b0d096b 100644
--- a/style.css
+++ b/style.css
@@ -2243,3 +2243,104 @@
display: block;
pointer-events: auto;
}
+
+/* --- Floating Ball (悬浮球) --- */
+#bme-floating-ball {
+ position: fixed;
+ z-index: 9998;
+ width: 46px;
+ height: 46px;
+ border-radius: 50%;
+ background: rgba(30, 30, 40, 0.75);
+ backdrop-filter: blur(12px) saturate(120%);
+ -webkit-backdrop-filter: blur(12px) saturate(120%);
+ border: 1.5px solid rgba(255, 255, 255, 0.12);
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255,255,255,0.05) inset;
+ cursor: grab;
+ user-select: none;
+ touch-action: none;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: box-shadow 0.3s ease, transform 0.15s ease, border-color 0.3s ease;
+}
+
+#bme-floating-ball:hover {
+ transform: scale(1.08);
+ border-color: var(--bme-accent, #e1415d);
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4), 0 0 12px var(--bme-accent, #e1415d40);
+}
+
+#bme-floating-ball:active {
+ cursor: grabbing;
+ transform: scale(0.95);
+}
+
+#bme-floating-ball .bme-fab-icon {
+ font-size: 18px;
+ color: rgba(255, 255, 255, 0.85);
+ pointer-events: none;
+ transition: color 0.3s ease;
+}
+
+#bme-floating-ball:hover .bme-fab-icon {
+ color: var(--bme-accent, #e1415d);
+}
+
+/* Status ring */
+#bme-floating-ball::after {
+ content: "";
+ position: absolute;
+ inset: -3px;
+ border-radius: 50%;
+ border: 2px solid transparent;
+ transition: border-color 0.3s ease;
+ pointer-events: none;
+}
+
+#bme-floating-ball[data-status="running"]::after {
+ border-color: var(--bme-accent, #e1415d);
+ animation: bme-fab-pulse 1.5s ease-in-out infinite;
+}
+
+#bme-floating-ball[data-status="success"]::after {
+ border-color: var(--bme-accent2, #4edea3);
+}
+
+#bme-floating-ball[data-status="error"]::after {
+ border-color: #f44336;
+}
+
+#bme-floating-ball[data-status="warning"]::after {
+ border-color: #ffc107;
+}
+
+@keyframes bme-fab-pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.35; }
+}
+
+/* Tooltip */
+#bme-floating-ball .bme-fab-tooltip {
+ position: absolute;
+ right: calc(100% + 10px);
+ top: 50%;
+ transform: translateY(-50%);
+ background: rgba(20, 20, 28, 0.92);
+ backdrop-filter: blur(10px);
+ -webkit-backdrop-filter: blur(10px);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 8px;
+ padding: 6px 10px;
+ font-size: 0.72rem;
+ color: rgba(255, 255, 255, 0.8);
+ white-space: nowrap;
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity 0.2s ease;
+}
+
+#bme-floating-ball:hover .bme-fab-tooltip {
+ opacity: 1;
+}
+