Преглед изворни кода

[Mod 0000]AI助手消息导航增加滚动跟随

wangkeyi пре 2 дана
родитељ
комит
c562d84e3f
1 измењених фајлова са 147 додато и 25 уклоњено
  1. 147 25
      src/views/ventAI/manageAssistent/components/chatModal/ChatMessages.vue

+ 147 - 25
src/views/ventAI/manageAssistent/components/chatModal/ChatMessages.vue

@@ -14,7 +14,7 @@
         v-for="(pair, pIdx) in qaPairs"
         :key="pIdx"
         :class="['nav-line', { 'nav-line-hot': nearestLineIndex === pIdx, active: activeMessage && activeMessage === messages[pair.startIndex] }]"
-        :style="{ width: `${navLineWidth(pIdx)}px` }"
+        :style="{ transform: `scaleX(${navLineScale(pIdx)})` }"
         @click="scrollToPair(pair)"
       ></div>
     </div>
@@ -903,7 +903,7 @@
 
   onMounted(() => {
     messagesRef.value?.addEventListener('click', handleTableAction);
-    messagesRef.value?.addEventListener('scroll', checkScrollBottom);
+    messagesRef.value?.addEventListener('scroll', handleMessagesScroll);
     timerInterval = setInterval(() => {
       nowTs.value = Date.now();
     }, 1000);
@@ -911,7 +911,19 @@
 
   onBeforeUnmount(() => {
     messagesRef.value?.removeEventListener('click', handleTableAction);
-    messagesRef.value?.removeEventListener('scroll', checkScrollBottom);
+    messagesRef.value?.removeEventListener('scroll', handleMessagesScroll);
+    if (spyRaf) {
+      cancelAnimationFrame(spyRaf);
+      spyRaf = 0;
+    }
+    if (navMoveRaf) {
+      cancelAnimationFrame(navMoveRaf);
+      navMoveRaf = 0;
+    }
+    if (navScrollRaf) {
+      cancelAnimationFrame(navScrollRaf);
+      navScrollRaf = 0;
+    }
     if (timerInterval) {
       clearInterval(timerInterval);
       timerInterval = null;
@@ -1047,12 +1059,13 @@
     lineCenters.value = centers;
   };
 
-  // 宽度随与鼠标的距离连续衰减(高斯),形成平滑的"长-中-短"波形
-  const navLineWidth = (pIdx: number): number => {
+  // 宽度随与鼠标的距离连续衰减(高斯),形成平滑的"长-中-短"波形;
+  // 用 scaleX 缩放(合成器动画)替代逐帧改 width(逐帧重排),滚动/移动时不再卡顿
+  const navLineScale = (pIdx: number): number => {
     const mouseY = navMouseY.value;
-    if (mouseY === null) return 10;
+    if (mouseY === null) return 1;
     const dist = Math.abs((lineCenters.value[pIdx] ?? 0) - mouseY);
-    return 10 + 8 * Math.exp(-(dist * dist) / (2 * 14 * 14));
+    return 1 + 0.8 * Math.exp(-(dist * dist) / (2 * 14 * 14));
   };
 
   const nearestLineIndex = computed(() => {
@@ -1091,7 +1104,10 @@
     tooltipArrowTop.value = Math.max(6, Math.min(tooltipH - 16, lineCenterY - tooltipTop.value - 5));
   };
 
-  const onNavMove = (e: MouseEvent) => {
+  let navMoveRaf = 0;
+  let lastNavMoveEvent: MouseEvent | null = null;
+
+  const applyNavMove = (e: MouseEvent) => {
     const nav = navRef.value;
     if (!nav) return;
     // 横线数量变化(新增问答/首次悬浮)时重建坐标
@@ -1107,22 +1123,49 @@
     }
   };
 
+  const onNavMove = (e: MouseEvent) => {
+    // 鼠标移动高频触发,合并到每帧处理一次,避免频繁重排造成卡顿
+    lastNavMoveEvent = e;
+    if (navMoveRaf) return;
+    navMoveRaf = requestAnimationFrame(() => {
+      navMoveRaf = 0;
+      if (lastNavMoveEvent) applyNavMove(lastNavMoveEvent);
+    });
+  };
+
   const onNavLeave = () => {
+    if (navMoveRaf) {
+      cancelAnimationFrame(navMoveRaf);
+      navMoveRaf = 0;
+      lastNavMoveEvent = null;
+    }
     navMouseY.value = null;
     hoveredPair.value = null;
     lastTooltipIndex = -1;
   };
 
+  let navScrollRaf = 0;
   const onNavScroll = () => {
-    updateLineCenters();
-    if (lastTooltipIndex >= 0) applyTooltip(lastTooltipIndex);
+    // 导航列自身滚动时同样按帧合并,保持滚动过程顺滑
+    if (navScrollRaf) return;
+    navScrollRaf = requestAnimationFrame(() => {
+      navScrollRaf = 0;
+      updateLineCenters();
+      if (lastTooltipIndex >= 0) applyTooltip(lastTooltipIndex);
+    });
   };
 
   // 仅滚动消息列表容器:scrollIntoView 会联动滚动 #adaptive-container 等祖先,导致整页上移、底部露出空白
   const scrollMessagesTo = (el: HTMLElement, block: 'start' | 'center' = 'start') => {
     const container = messagesRef.value;
     if (!container) return;
-    const top = el.getBoundingClientRect().top - container.getBoundingClientRect().top + container.scrollTop;
+    // 容器处于自适应缩放环境:getBoundingClientRect 是缩放后的视口坐标,scrollTop 是未缩放的布局坐标,
+    // 直接混算在缩放比非 1(打包部署后的其他分辨率)时定位偏差,需除以纵向缩放比换算回布局坐标
+    //(与左侧对话缩略导航 tooltip 的换算方式一致)
+    const rect = container.getBoundingClientRect();
+    const scale = container.offsetHeight ? rect.height / container.offsetHeight : 1;
+    const safeScale = scale > 0 && Number.isFinite(scale) ? scale : 1;
+    const top = (el.getBoundingClientRect().top - rect.top) / safeScale + container.scrollTop;
     const offset = block === 'center' ? (container.clientHeight - el.offsetHeight) / 2 : 0;
     container.scrollTo({ top: Math.max(0, top - offset), behavior: 'smooth' });
   };
@@ -1147,12 +1190,81 @@
   const scrollToPair = (pair: QaPair) => {
     activeMessage.value = props.messages[pair.startIndex];
     scrollToMessage(pair.startIndex, 'start');
+    const pIdx = qaPairs.value.indexOf(pair);
+    if (pIdx >= 0) ensureNavLineVisible(pIdx);
     const wrappers = messagesRef.value?.querySelectorAll('.message-wrapper');
     const el = wrappers?.[pair.startIndex] as HTMLElement | undefined;
     el?.classList.add('nav-located-highlight');
     setTimeout(() => el?.classList.remove('nav-located-highlight'), 1500);
   };
 
+  // 选中线保持在导航列可视区内(长对话时导航列自身可滚动):
+  // 仅在本列内部调整 scrollTop,不调用 scrollIntoView 以免联动祖先容器
+  const ensureNavLineVisible = (pIdx: number) => {
+    const nav = navRef.value;
+    const line = nav?.querySelectorAll<HTMLElement>('.nav-line')[pIdx];
+    if (!nav || !line) return;
+    const margin = 12;
+    const top = line.offsetTop;
+    const bottom = top + line.offsetHeight;
+    if (top < nav.scrollTop + margin) {
+      nav.scrollTop = Math.max(0, top - margin);
+    } else if (bottom > nav.scrollTop + nav.clientHeight - margin) {
+      nav.scrollTop = bottom - nav.clientHeight + margin;
+    }
+  };
+
+  // ===== 滚动跟随:消息滚动时,导航选中项实时跟随当前正在阅读的一组问答 =====
+  let spyRaf = 0;
+  const updateActivePairByScroll = () => {
+    const pairs = qaPairs.value;
+    const container = messagesRef.value;
+    if (!pairs.length || !container) {
+      activeMessage.value = null;
+      return;
+    }
+    const wrappers = container.querySelectorAll('.message-wrapper');
+    // 以视口上部 1/3 处为判定线:起始位置越过判定线的一组即为当前阅读组;触底时强制选中最后一组
+    const mark = container.scrollTop + container.clientHeight * 0.35;
+    let current = 0;
+    for (let p = 0; p < pairs.length; p++) {
+      const el = wrappers[pairs[p].startIndex] as HTMLElement | undefined;
+      if (!el) continue;
+      if (el.offsetTop <= mark) current = p;
+      else break;
+    }
+    if (isAtBottom.value) current = pairs.length - 1;
+    const target = props.messages[pairs[current].startIndex] ?? null;
+    if (activeMessage.value !== target) {
+      activeMessage.value = target;
+      // 选中项变化时把对应横线滚入导航列可视区,跟随才真正可见
+      ensureNavLineVisible(current);
+    }
+  };
+
+  // 滚动事件高频触发,用 rAF 合并到每帧最多计算一次
+  const scheduleSpy = () => {
+    if (spyRaf) return;
+    spyRaf = requestAnimationFrame(() => {
+      spyRaf = 0;
+      updateActivePairByScroll();
+    });
+  };
+
+  // 消息增减(新问答、加载历史、切换任务)后同步一次选中状态
+  watch(
+    () => qaPairs.value.length,
+    () => {
+      nextTick(updateActivePairByScroll);
+    },
+    { immediate: true }
+  );
+
+  const handleMessagesScroll = () => {
+    checkScrollBottom();
+    scheduleSpy();
+  };
+
   defineExpose({ messagesRef, isAtBottom, forceScrollToBottom, scrollToMessage, initAskUserAnswers, isAnswerVisible });
 </script>
 
@@ -1184,13 +1296,15 @@
       width: 10px;
       height: 3px;
       border-radius: 2px;
-      background: rgba(139, 148, 158, 0.4);
+      background: rgba(139, 148, 158, 0.35);
       margin: 5px 0;
       flex-shrink: 0;
       cursor: pointer;
+      transform-origin: center;
       transition:
-        width 0.15s ease-out,
-        background 0.2s;
+        transform 0.18s cubic-bezier(0.33, 1, 0.68, 1),
+        background 0.2s,
+        box-shadow 0.2s;
 
       &:first-child {
         margin-top: auto;
@@ -1201,11 +1315,12 @@
       }
 
       &.nav-line-hot {
-        background: #0a84ff;
+        background: linear-gradient(90deg, rgba(10, 132, 255, 0.6) 0%, #0a84ff 100%);
       }
 
       &.active {
-        background: #0a84ff;
+        background: linear-gradient(90deg, #2f9bff 0%, #0a84ff 100%);
+        box-shadow: 0 0 6px rgba(10, 132, 255, 0.45);
       }
     }
   }
@@ -1216,8 +1331,10 @@
     width: 260px;
     background: linear-gradient(135deg, #0a1628 0%, #0d2137 50%, #0a1e35 100%);
     border: 1px solid #1a4a6f;
-    border-radius: 8px;
-    box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
+    border-radius: 10px;
+    box-shadow:
+      0 8px 24px rgba(0, 0, 0, 0.5),
+      0 0 14px rgba(10, 132, 255, 0.08);
     padding: 10px 12px;
     z-index: 20;
     pointer-events: none;
@@ -1231,6 +1348,7 @@
       background: #0d2137;
       border-left: 1px solid #1a4a6f;
       border-bottom: 1px solid #1a4a6f;
+      border-top-left-radius: 2px;
     }
 
     .tooltip-row {
@@ -1238,12 +1356,12 @@
       align-items: flex-start;
       gap: 8px;
       font-size: 12px;
-      line-height: 1.5;
+      line-height: 1.6;
 
       & + .tooltip-row {
         margin-top: 8px;
         padding-top: 8px;
-        border-top: 1px solid rgba(63, 80, 106, 0.3);
+        border-top: 1px solid rgba(63, 80, 106, 0.35);
       }
 
       .tooltip-label {
@@ -1251,10 +1369,11 @@
         width: 16px;
         height: 16px;
         line-height: 16px;
-        margin-top: 1px;
+        margin-top: 2px;
         text-align: center;
-        border-radius: 3px;
+        border-radius: 4px;
         font-size: 11px;
+        font-weight: 500;
 
         &-q {
           color: #79c0ff;
@@ -1262,13 +1381,13 @@
         }
 
         &-a {
-          color: #8b949e;
+          color: #a9b9c9;
           background: rgba(139, 148, 158, 0.15);
         }
       }
 
       .tooltip-text {
-        color: #c9d1d9;
+        color: #d7dee6;
         word-break: break-word;
       }
     }
@@ -1276,12 +1395,15 @@
 
   .tooltip-fade-enter-active,
   .tooltip-fade-leave-active {
-    transition: opacity 0.15s ease;
+    transition:
+      opacity 0.18s ease,
+      transform 0.18s ease;
   }
 
   .tooltip-fade-enter-from,
   .tooltip-fade-leave-to {
     opacity: 0;
+    transform: translateX(-4px);
   }
 
   .chat-messages {