mirror of
https://gitee.com/ccnetcore/Yi
synced 2026-04-18 07:06:36 +08:00
fix: 滚动条优化
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { AnyObject } from 'typescript-api-pro';
|
import type { AnyObject } from 'typescript-api-pro';
|
||||||
import type { BubbleListInstance } from 'vue-element-plus-x/types/BubbleList';
|
import type { BubbleListInstance } from 'vue-element-plus-x/types/BubbleList';
|
||||||
import { Loading } from '@element-plus/icons-vue';
|
import { Loading, Bottom } from '@element-plus/icons-vue';
|
||||||
import { ElIcon, ElMessage } from 'element-plus';
|
import { ElIcon, ElMessage } from 'element-plus';
|
||||||
import { useHookFetch } from 'hook-fetch/vue';
|
import { useHookFetch } from 'hook-fetch/vue';
|
||||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
@@ -34,6 +34,12 @@ const bubbleItems = ref<MessageItemType[]>([]);
|
|||||||
const bubbleListRef = ref<BubbleListInstance | null>(null);
|
const bubbleListRef = ref<BubbleListInstance | null>(null);
|
||||||
const isSending = ref(false);
|
const isSending = ref(false);
|
||||||
|
|
||||||
|
// 智能滚动相关状态
|
||||||
|
const isUserAtBottom = ref(true);
|
||||||
|
const scrollContainer = ref<HTMLElement | null>(null);
|
||||||
|
const SCROLL_THRESHOLD = 150; // 距离底部多少像素视为"在底部"
|
||||||
|
let scrollUpdateTimer: number | null = null;
|
||||||
|
|
||||||
// 删除模式相关状态
|
// 删除模式相关状态
|
||||||
const isDeleteMode = ref(false);
|
const isDeleteMode = ref(false);
|
||||||
const selectedMessageIds = ref<(number | string)[]>([]);
|
const selectedMessageIds = ref<(number | string)[]>([]);
|
||||||
@@ -129,7 +135,57 @@ watch(
|
|||||||
);
|
);
|
||||||
|
|
||||||
function scrollToBottom() {
|
function scrollToBottom() {
|
||||||
setTimeout(() => bubbleListRef.value?.scrollToBottom(), 350);
|
setTimeout(() => {
|
||||||
|
if (bubbleListRef.value) {
|
||||||
|
bubbleListRef.value.scrollToBottom();
|
||||||
|
checkIfAtBottom();
|
||||||
|
}
|
||||||
|
}, 350);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测用户是否在底部(智能滚动核心)
|
||||||
|
*/
|
||||||
|
function checkIfAtBottom() {
|
||||||
|
const container = scrollContainer.value;
|
||||||
|
if (!container) {
|
||||||
|
isUserAtBottom.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||||
|
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
|
||||||
|
isUserAtBottom.value = distanceFromBottom < SCROLL_THRESHOLD;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 智能滚动到底部(仅在用户已在底部时滚动)
|
||||||
|
*/
|
||||||
|
function smartScrollToBottom(force = false) {
|
||||||
|
if (!scrollContainer.value) return;
|
||||||
|
|
||||||
|
// 强制滚动或用户已在底部时才滚动
|
||||||
|
if (force || isUserAtBottom.value) {
|
||||||
|
const targetScroll = scrollContainer.value.scrollHeight - scrollContainer.value.clientHeight;
|
||||||
|
scrollContainer.value.scrollTo({
|
||||||
|
top: targetScroll,
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
isUserAtBottom.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理滚动事件,检测用户位置
|
||||||
|
*/
|
||||||
|
function handleScroll() {
|
||||||
|
// 使用节流优化性能
|
||||||
|
if (scrollUpdateTimer !== null) {
|
||||||
|
clearTimeout(scrollUpdateTimer);
|
||||||
|
}
|
||||||
|
scrollUpdateTimer = window.setTimeout(() => {
|
||||||
|
checkIfAtBottom();
|
||||||
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -177,6 +233,8 @@ function handleDataChunk(chunk: AnyObject) {
|
|||||||
latest.loading = true;
|
latest.loading = true;
|
||||||
latest.thinlCollapse = true;
|
latest.thinlCollapse = true;
|
||||||
latest.reasoning_content += parsed.reasoning_content;
|
latest.reasoning_content += parsed.reasoning_content;
|
||||||
|
// 流式更新时智能滚动
|
||||||
|
nextTick(() => smartScrollToBottom());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理普通内容
|
// 处理普通内容
|
||||||
@@ -192,10 +250,13 @@ function handleDataChunk(chunk: AnyObject) {
|
|||||||
latest.loading = true;
|
latest.loading = true;
|
||||||
latest.thinlCollapse = true;
|
latest.thinlCollapse = true;
|
||||||
latest.reasoning_content += parsed.content.replace('<think>', '').replace('</think>', '');
|
latest.reasoning_content += parsed.content.replace('<think>', '').replace('</think>', '');
|
||||||
|
nextTick(() => smartScrollToBottom());
|
||||||
} else {
|
} else {
|
||||||
latest.thinkingStatus = 'end';
|
latest.thinkingStatus = 'end';
|
||||||
latest.loading = false;
|
latest.loading = false;
|
||||||
latest.content += parsed.content;
|
latest.content += parsed.content;
|
||||||
|
// 流式更新时智能滚动
|
||||||
|
nextTick(() => smartScrollToBottom());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -532,6 +593,10 @@ onMounted(() => {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener('paste', handlePaste);
|
document.removeEventListener('paste', handlePaste);
|
||||||
|
// 清理滚动定时器
|
||||||
|
if (scrollUpdateTimer !== null) {
|
||||||
|
clearTimeout(scrollUpdateTimer);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -551,12 +616,13 @@ onUnmounted(() => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 消息列表 -->
|
<!-- 消息列表 -->
|
||||||
<BubbleList
|
<div ref="scrollContainer" class="chat-with-id__messages-wrapper" @scroll="handleScroll">
|
||||||
ref="bubbleListRef"
|
<BubbleList
|
||||||
:list="bubbleItems"
|
ref="bubbleListRef"
|
||||||
max-height="calc(100vh - 240px)"
|
:list="bubbleItems"
|
||||||
:class="{ 'delete-mode': isDeleteMode }"
|
max-height="calc(100vh - 240px)"
|
||||||
>
|
:class="{ 'delete-mode': isDeleteMode }"
|
||||||
|
>
|
||||||
<template #content="{ item }">
|
<template #content="{ item }">
|
||||||
<MessageItem
|
<MessageItem
|
||||||
:item="item"
|
:item="item"
|
||||||
@@ -578,6 +644,18 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</BubbleList>
|
</BubbleList>
|
||||||
|
|
||||||
|
<!-- 滚动到底部按钮 -->
|
||||||
|
<Transition name="scroll-btn-fade">
|
||||||
|
<button
|
||||||
|
v-show="!isUserAtBottom"
|
||||||
|
class="chat-with-id__scroll-bottom-btn"
|
||||||
|
@click="smartScrollToBottom(true)"
|
||||||
|
>
|
||||||
|
<ElIcon><Bottom /></ElIcon>
|
||||||
|
</button>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 发送器 -->
|
<!-- 发送器 -->
|
||||||
<Sender
|
<Sender
|
||||||
ref="senderRef"
|
ref="senderRef"
|
||||||
@@ -689,6 +767,84 @@ onUnmounted(() => {
|
|||||||
color: var(--el-color-primary);
|
color: var(--el-color-primary);
|
||||||
animation: rotating 2s linear infinite;
|
animation: rotating 2s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 消息包装器样式 ====================
|
||||||
|
&__messages-wrapper {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
|
||||||
|
// 平滑滚动
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
|
||||||
|
// Webkit 浏览器滚动条样式 (Chrome, Safari, Edge)
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(144, 147, 153, 0.3);
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(144, 147, 153, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Firefox 滚动条样式
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(144, 147, 153, 0.3) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 滚动到底部按钮 ====================
|
||||||
|
&__scroll-bottom-btn {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 18px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--el-color-primary);
|
||||||
|
border-color: var(--el-color-primary);
|
||||||
|
color: #fff;
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
bottom: 16px;
|
||||||
|
right: 16px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 气泡列表基础样式覆盖
|
// 气泡列表基础样式覆盖
|
||||||
@@ -752,4 +908,16 @@ onUnmounted(() => {
|
|||||||
0% { transform: rotate(0deg); }
|
0% { transform: rotate(0deg); }
|
||||||
100% { transform: rotate(360deg); }
|
100% { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 滚动按钮过渡动画 ====================
|
||||||
|
.scroll-btn-fade-enter-active,
|
||||||
|
.scroll-btn-fade-leave-active {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-btn-fade-enter-from,
|
||||||
|
.scroll-btn-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user