|
@@ -14,7 +14,7 @@
|
|
|
v-for="(pair, pIdx) in qaPairs"
|
|
v-for="(pair, pIdx) in qaPairs"
|
|
|
:key="pIdx"
|
|
:key="pIdx"
|
|
|
:class="['nav-line', { 'nav-line-hot': nearestLineIndex === pIdx, active: activeMessage && activeMessage === messages[pair.startIndex] }]"
|
|
: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)"
|
|
@click="scrollToPair(pair)"
|
|
|
></div>
|
|
></div>
|
|
|
</div>
|
|
</div>
|
|
@@ -903,7 +903,7 @@
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
onMounted(() => {
|
|
|
messagesRef.value?.addEventListener('click', handleTableAction);
|
|
messagesRef.value?.addEventListener('click', handleTableAction);
|
|
|
- messagesRef.value?.addEventListener('scroll', checkScrollBottom);
|
|
|
|
|
|
|
+ messagesRef.value?.addEventListener('scroll', handleMessagesScroll);
|
|
|
timerInterval = setInterval(() => {
|
|
timerInterval = setInterval(() => {
|
|
|
nowTs.value = Date.now();
|
|
nowTs.value = Date.now();
|
|
|
}, 1000);
|
|
}, 1000);
|
|
@@ -911,7 +911,19 @@
|
|
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
onBeforeUnmount(() => {
|
|
|
messagesRef.value?.removeEventListener('click', handleTableAction);
|
|
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) {
|
|
if (timerInterval) {
|
|
|
clearInterval(timerInterval);
|
|
clearInterval(timerInterval);
|
|
|
timerInterval = null;
|
|
timerInterval = null;
|
|
@@ -1047,12 +1059,13 @@
|
|
|
lineCenters.value = centers;
|
|
lineCenters.value = centers;
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
- // 宽度随与鼠标的距离连续衰减(高斯),形成平滑的"长-中-短"波形
|
|
|
|
|
- const navLineWidth = (pIdx: number): number => {
|
|
|
|
|
|
|
+ // 宽度随与鼠标的距离连续衰减(高斯),形成平滑的"长-中-短"波形;
|
|
|
|
|
+ // 用 scaleX 缩放(合成器动画)替代逐帧改 width(逐帧重排),滚动/移动时不再卡顿
|
|
|
|
|
+ const navLineScale = (pIdx: number): number => {
|
|
|
const mouseY = navMouseY.value;
|
|
const mouseY = navMouseY.value;
|
|
|
- if (mouseY === null) return 10;
|
|
|
|
|
|
|
+ if (mouseY === null) return 1;
|
|
|
const dist = Math.abs((lineCenters.value[pIdx] ?? 0) - mouseY);
|
|
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(() => {
|
|
const nearestLineIndex = computed(() => {
|
|
@@ -1091,7 +1104,10 @@
|
|
|
tooltipArrowTop.value = Math.max(6, Math.min(tooltipH - 16, lineCenterY - tooltipTop.value - 5));
|
|
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;
|
|
const nav = navRef.value;
|
|
|
if (!nav) return;
|
|
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 = () => {
|
|
const onNavLeave = () => {
|
|
|
|
|
+ if (navMoveRaf) {
|
|
|
|
|
+ cancelAnimationFrame(navMoveRaf);
|
|
|
|
|
+ navMoveRaf = 0;
|
|
|
|
|
+ lastNavMoveEvent = null;
|
|
|
|
|
+ }
|
|
|
navMouseY.value = null;
|
|
navMouseY.value = null;
|
|
|
hoveredPair.value = null;
|
|
hoveredPair.value = null;
|
|
|
lastTooltipIndex = -1;
|
|
lastTooltipIndex = -1;
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
|
|
+ let navScrollRaf = 0;
|
|
|
const onNavScroll = () => {
|
|
const onNavScroll = () => {
|
|
|
- updateLineCenters();
|
|
|
|
|
- if (lastTooltipIndex >= 0) applyTooltip(lastTooltipIndex);
|
|
|
|
|
|
|
+ // 导航列自身滚动时同样按帧合并,保持滚动过程顺滑
|
|
|
|
|
+ if (navScrollRaf) return;
|
|
|
|
|
+ navScrollRaf = requestAnimationFrame(() => {
|
|
|
|
|
+ navScrollRaf = 0;
|
|
|
|
|
+ updateLineCenters();
|
|
|
|
|
+ if (lastTooltipIndex >= 0) applyTooltip(lastTooltipIndex);
|
|
|
|
|
+ });
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
// 仅滚动消息列表容器:scrollIntoView 会联动滚动 #adaptive-container 等祖先,导致整页上移、底部露出空白
|
|
// 仅滚动消息列表容器:scrollIntoView 会联动滚动 #adaptive-container 等祖先,导致整页上移、底部露出空白
|
|
|
const scrollMessagesTo = (el: HTMLElement, block: 'start' | 'center' = 'start') => {
|
|
const scrollMessagesTo = (el: HTMLElement, block: 'start' | 'center' = 'start') => {
|
|
|
const container = messagesRef.value;
|
|
const container = messagesRef.value;
|
|
|
if (!container) return;
|
|
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;
|
|
const offset = block === 'center' ? (container.clientHeight - el.offsetHeight) / 2 : 0;
|
|
|
container.scrollTo({ top: Math.max(0, top - offset), behavior: 'smooth' });
|
|
container.scrollTo({ top: Math.max(0, top - offset), behavior: 'smooth' });
|
|
|
};
|
|
};
|
|
@@ -1147,12 +1190,81 @@
|
|
|
const scrollToPair = (pair: QaPair) => {
|
|
const scrollToPair = (pair: QaPair) => {
|
|
|
activeMessage.value = props.messages[pair.startIndex];
|
|
activeMessage.value = props.messages[pair.startIndex];
|
|
|
scrollToMessage(pair.startIndex, 'start');
|
|
scrollToMessage(pair.startIndex, 'start');
|
|
|
|
|
+ const pIdx = qaPairs.value.indexOf(pair);
|
|
|
|
|
+ if (pIdx >= 0) ensureNavLineVisible(pIdx);
|
|
|
const wrappers = messagesRef.value?.querySelectorAll('.message-wrapper');
|
|
const wrappers = messagesRef.value?.querySelectorAll('.message-wrapper');
|
|
|
const el = wrappers?.[pair.startIndex] as HTMLElement | undefined;
|
|
const el = wrappers?.[pair.startIndex] as HTMLElement | undefined;
|
|
|
el?.classList.add('nav-located-highlight');
|
|
el?.classList.add('nav-located-highlight');
|
|
|
setTimeout(() => el?.classList.remove('nav-located-highlight'), 1500);
|
|
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 });
|
|
defineExpose({ messagesRef, isAtBottom, forceScrollToBottom, scrollToMessage, initAskUserAnswers, isAnswerVisible });
|
|
|
</script>
|
|
</script>
|
|
|
|
|
|
|
@@ -1184,13 +1296,15 @@
|
|
|
width: 10px;
|
|
width: 10px;
|
|
|
height: 3px;
|
|
height: 3px;
|
|
|
border-radius: 2px;
|
|
border-radius: 2px;
|
|
|
- background: rgba(139, 148, 158, 0.4);
|
|
|
|
|
|
|
+ background: rgba(139, 148, 158, 0.35);
|
|
|
margin: 5px 0;
|
|
margin: 5px 0;
|
|
|
flex-shrink: 0;
|
|
flex-shrink: 0;
|
|
|
cursor: pointer;
|
|
cursor: pointer;
|
|
|
|
|
+ transform-origin: center;
|
|
|
transition:
|
|
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 {
|
|
&:first-child {
|
|
|
margin-top: auto;
|
|
margin-top: auto;
|
|
@@ -1201,11 +1315,12 @@
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
&.nav-line-hot {
|
|
&.nav-line-hot {
|
|
|
- background: #0a84ff;
|
|
|
|
|
|
|
+ background: linear-gradient(90deg, rgba(10, 132, 255, 0.6) 0%, #0a84ff 100%);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
&.active {
|
|
&.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;
|
|
width: 260px;
|
|
|
background: linear-gradient(135deg, #0a1628 0%, #0d2137 50%, #0a1e35 100%);
|
|
background: linear-gradient(135deg, #0a1628 0%, #0d2137 50%, #0a1e35 100%);
|
|
|
border: 1px solid #1a4a6f;
|
|
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;
|
|
padding: 10px 12px;
|
|
|
z-index: 20;
|
|
z-index: 20;
|
|
|
pointer-events: none;
|
|
pointer-events: none;
|
|
@@ -1231,6 +1348,7 @@
|
|
|
background: #0d2137;
|
|
background: #0d2137;
|
|
|
border-left: 1px solid #1a4a6f;
|
|
border-left: 1px solid #1a4a6f;
|
|
|
border-bottom: 1px solid #1a4a6f;
|
|
border-bottom: 1px solid #1a4a6f;
|
|
|
|
|
+ border-top-left-radius: 2px;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
.tooltip-row {
|
|
.tooltip-row {
|
|
@@ -1238,12 +1356,12 @@
|
|
|
align-items: flex-start;
|
|
align-items: flex-start;
|
|
|
gap: 8px;
|
|
gap: 8px;
|
|
|
font-size: 12px;
|
|
font-size: 12px;
|
|
|
- line-height: 1.5;
|
|
|
|
|
|
|
+ line-height: 1.6;
|
|
|
|
|
|
|
|
& + .tooltip-row {
|
|
& + .tooltip-row {
|
|
|
margin-top: 8px;
|
|
margin-top: 8px;
|
|
|
padding-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 {
|
|
.tooltip-label {
|
|
@@ -1251,10 +1369,11 @@
|
|
|
width: 16px;
|
|
width: 16px;
|
|
|
height: 16px;
|
|
height: 16px;
|
|
|
line-height: 16px;
|
|
line-height: 16px;
|
|
|
- margin-top: 1px;
|
|
|
|
|
|
|
+ margin-top: 2px;
|
|
|
text-align: center;
|
|
text-align: center;
|
|
|
- border-radius: 3px;
|
|
|
|
|
|
|
+ border-radius: 4px;
|
|
|
font-size: 11px;
|
|
font-size: 11px;
|
|
|
|
|
+ font-weight: 500;
|
|
|
|
|
|
|
|
&-q {
|
|
&-q {
|
|
|
color: #79c0ff;
|
|
color: #79c0ff;
|
|
@@ -1262,13 +1381,13 @@
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
&-a {
|
|
&-a {
|
|
|
- color: #8b949e;
|
|
|
|
|
|
|
+ color: #a9b9c9;
|
|
|
background: rgba(139, 148, 158, 0.15);
|
|
background: rgba(139, 148, 158, 0.15);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
.tooltip-text {
|
|
.tooltip-text {
|
|
|
- color: #c9d1d9;
|
|
|
|
|
|
|
+ color: #d7dee6;
|
|
|
word-break: break-word;
|
|
word-break: break-word;
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -1276,12 +1395,15 @@
|
|
|
|
|
|
|
|
.tooltip-fade-enter-active,
|
|
.tooltip-fade-enter-active,
|
|
|
.tooltip-fade-leave-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-enter-from,
|
|
|
.tooltip-fade-leave-to {
|
|
.tooltip-fade-leave-to {
|
|
|
opacity: 0;
|
|
opacity: 0;
|
|
|
|
|
+ transform: translateX(-4px);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
.chat-messages {
|
|
.chat-messages {
|