From 7d8a80ae8d8ab3e54c769363439b697613a810e9 Mon Sep 17 00:00:00 2001 From: Binks Riverton Date: Fri, 26 Jun 2026 22:26:27 +0800 Subject: [PATCH] fix(frontend): reposition card hover popover to card instead of cursor Change the knowledge card hover popover from following the mouse to being anchored to the card element with smart boundary detection. Try positioning in order: right, left, bottom, and top of the card, automatically avoiding viewport edges. Also add CSS hints (will-change, backface-visibility, translateZ) to prevent popover content jitter. --- .../src/views/knowledge/KnowledgeBase.vue | 118 ++++++++++++++++-- 1 file changed, 106 insertions(+), 12 deletions(-) diff --git a/frontend/src/views/knowledge/KnowledgeBase.vue b/frontend/src/views/knowledge/KnowledgeBase.vue index 4fc18cd1..4943096e 100644 --- a/frontend/src/views/knowledge/KnowledgeBase.vue +++ b/frontend/src/views/knowledge/KnowledgeBase.vue @@ -1226,35 +1226,121 @@ const openSourceDoc = (knowledgeId: string) => { getCardDetails({ id: knowledgeId }); }; -// 悬停知识卡片时跟随鼠标显示详情气泡 +// 悬停知识卡片时显示详情气泡(基于卡片位置定位) const hoveredCardItem = ref(null); const cardPopoverPos = ref({ x: 0, y: 0 }); -const CARD_POPOVER_OFFSET = 16; +const CARD_POPOVER_OFFSET = 12; +const CARD_POPOVER_ESTIMATED_WIDTH = 360; +const CARD_POPOVER_ESTIMATED_HEIGHT = 300; const cardHoverShowDelay = 300; let cardHoverTimer: ReturnType | null = null; +let cardPopoverElement: HTMLElement | null = null; + +// 根据卡片位置计算气泡位置(优先右侧,自动避开边界) +const calculatePopoverPositionFromCard = (cardElement: HTMLElement): { x: number; y: number } => { + const cardRect = cardElement.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + // 获取实际气泡尺寸 + let popoverWidth = CARD_POPOVER_ESTIMATED_WIDTH; + let popoverHeight = CARD_POPOVER_ESTIMATED_HEIGHT; + + if (cardPopoverElement) { + const rect = cardPopoverElement.getBoundingClientRect(); + if (rect.width > 0) popoverWidth = rect.width; + if (rect.height > 0) popoverHeight = rect.height; + } + + let x = 0; + let y = 0; + + // 策略1:优先尝试放在卡片右侧 + const rightX = cardRect.right + CARD_POPOVER_OFFSET; + if (rightX + popoverWidth <= viewportWidth - 10) { + x = rightX; + y = cardRect.top; + // 确保不超出底部 + if (y + popoverHeight > viewportHeight - 10) { + y = viewportHeight - popoverHeight - 10; + } + // 确保不超出顶部 + y = Math.max(10, y); + return { x, y }; + } + + // 策略2:尝试放在卡片左侧 + const leftX = cardRect.left - popoverWidth - CARD_POPOVER_OFFSET; + if (leftX >= 10) { + x = leftX; + y = cardRect.top; + // 确保不超出底部 + if (y + popoverHeight > viewportHeight - 10) { + y = viewportHeight - popoverHeight - 10; + } + // 确保不超出顶部 + y = Math.max(10, y); + return { x, y }; + } + + // 策略3:尝试放在卡片下方 + const bottomY = cardRect.bottom + CARD_POPOVER_OFFSET; + if (bottomY + popoverHeight <= viewportHeight - 10) { + y = bottomY; + x = cardRect.left; + // 确保不超出右边界 + if (x + popoverWidth > viewportWidth - 10) { + x = viewportWidth - popoverWidth - 10; + } + // 确保不超出左边界 + x = Math.max(10, x); + return { x, y }; + } + + // 策略4:放在卡片上方 + const topY = cardRect.top - popoverHeight - CARD_POPOVER_OFFSET; + y = Math.max(10, topY); + x = cardRect.left; + // 确保不超出右边界 + if (x + popoverWidth > viewportWidth - 10) { + x = viewportWidth - popoverWidth - 10; + } + // 确保不超出左边界 + x = Math.max(10, x); + + return { x, y }; +}; const onCardMouseEnter = (ev: MouseEvent, item: KnowledgeCard) => { if (cardHoverTimer) { clearTimeout(cardHoverTimer); cardHoverTimer = null; } + + const cardElement = (ev.currentTarget as HTMLElement); + cardHoverTimer = setTimeout(() => { cardHoverTimer = null; hoveredCardItem.value = item; - cardPopoverPos.value = { - x: ev.clientX + CARD_POPOVER_OFFSET, - y: ev.clientY + CARD_POPOVER_OFFSET, - }; + + // 基于卡片位置计算气泡位置 + const pos = calculatePopoverPositionFromCard(cardElement); + cardPopoverPos.value = pos; + + // 获取实际元素后精确计算 + nextTick(() => { + cardPopoverElement = document.querySelector('.knowledge-card-hover-popover') as HTMLElement; + if (cardPopoverElement) { + const refinedPos = calculatePopoverPositionFromCard(cardElement); + cardPopoverPos.value = refinedPos; + } + }); }, cardHoverShowDelay); }; +// 鼠标在卡片上移动时不更新气泡位置 const onCardMouseMove = (ev: MouseEvent) => { - if (hoveredCardItem.value) { - cardPopoverPos.value = { - x: ev.clientX + CARD_POPOVER_OFFSET, - y: ev.clientY + CARD_POPOVER_OFFSET, - }; - } + // 保持气泡固定在卡片旁边 }; const onCardMouseLeave = () => { @@ -1263,6 +1349,7 @@ const onCardMouseLeave = () => { cardHoverTimer = null; } hoveredCardItem.value = null; + cardPopoverElement = null; }; const closeCardMoreMenu = (index: number) => { @@ -4145,6 +4232,13 @@ async function createNewSession(value: string): Promise { box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); font-family: var(--app-font-family); transition: opacity 0.15s ease; + will-change: transform; + + /* 防止气泡内容抖动 */ + backface-visibility: hidden; + -webkit-backface-visibility: hidden; + transform: translateZ(0); + -webkit-transform: translateZ(0); .card-popover-title { font-size: 14px;