fix: 修复聊天预览滚动抽搐与键盘事件类型

任务: 解决问答预览页滚动抽搐并补齐类型检查报错\n\n- 在 chat 组件中改为自定义回到底部按钮,避免内置按钮导致滚动抖动\n- 统一收口流式结束状态,结束时关闭 typing/loading\n- 修复 FaqEditDialog 的 keydown 参数类型,兼容 Event | KeyboardEvent
This commit is contained in:
2026-02-25 19:47:25 +08:00
parent 371b8cf891
commit fcf1100b56
2 changed files with 143 additions and 22 deletions

View File

@@ -1,12 +1,15 @@
<script setup lang="ts">
import type { Sender } from 'vue-element-plus-x';
import type { BubbleListProps } from 'vue-element-plus-x/types/BubbleList';
import type {
BubbleListInstance,
BubbleListProps,
} from 'vue-element-plus-x/types/BubbleList';
import type { ThinkingStatus } from 'vue-element-plus-x/types/Thinking';
import type { TypewriterInstance } from 'vue-element-plus-x/types/Typewriter';
import type { BotInfo, ChatMessage } from '@easyflow/types';
import { onMounted, ref, watchEffect } from 'vue';
import { nextTick, onBeforeUnmount, onMounted, ref, watch, watchEffect } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { IconifyIcon } from '@easyflow/icons';
@@ -15,6 +18,7 @@ import { useBotStore } from '@easyflow/stores';
import { cloneDeep, cn, uuid } from '@easyflow/utils';
import {
ArrowDownBold,
CircleCheck,
CopyDocument,
Paperclip,
@@ -77,9 +81,14 @@ const botId = ref<string>((route.params.id as string) || '');
const router = useRouter();
const bubbleItems = ref<BubbleListProps<MessageItem>['list']>([]);
const bubbleListRef = ref<BubbleListInstance>();
const messageContainerRef = ref<HTMLElement | null>(null);
const bubbleListScrollElement = ref<HTMLElement | null>(null);
const showBackToBottomButton = ref(false);
const senderRef = ref<InstanceType<typeof Sender>>();
const senderValue = ref('');
const sending = ref(false);
const BACK_TO_BOTTOM_THRESHOLD = 160;
const getConversationId = async () => {
const res = await api.get('/api/v1/bot/generateConversationId');
return res.data;
@@ -121,6 +130,9 @@ onMounted(async () => {
? props.conversationId
: await getConversationId();
getPresetQuestions();
nextTick(() => {
bindBubbleListScroll();
});
});
watchEffect(async () => {
if (props.bot && props.conversationId) {
@@ -146,18 +158,64 @@ watchEffect(async () => {
});
const lastUserMessage = ref('');
const messages = ref<historyMessageType[]>([]);
const updateBackToBottomButtonVisible = () => {
if (!bubbleListScrollElement.value) {
showBackToBottomButton.value = false;
return;
}
const { scrollTop, scrollHeight, clientHeight } = bubbleListScrollElement.value;
showBackToBottomButton.value =
scrollHeight - (scrollTop + clientHeight) > BACK_TO_BOTTOM_THRESHOLD;
};
const handleBubbleListScroll = () => {
updateBackToBottomButtonVisible();
};
const bindBubbleListScroll = () => {
const nextScrollElement = messageContainerRef.value?.querySelector(
'.el-bubble-list',
) as HTMLElement | null;
if (bubbleListScrollElement.value === nextScrollElement) {
updateBackToBottomButtonVisible();
return;
}
if (bubbleListScrollElement.value) {
bubbleListScrollElement.value.removeEventListener(
'scroll',
handleBubbleListScroll,
);
}
bubbleListScrollElement.value = nextScrollElement;
if (bubbleListScrollElement.value) {
bubbleListScrollElement.value.addEventListener(
'scroll',
handleBubbleListScroll,
{
passive: true,
},
);
}
updateBackToBottomButtonVisible();
};
const updateLastBubbleItem = (patch: Partial<MessageItem>) => {
const lastIndex = bubbleItems.value.length - 1;
if (lastIndex < 0) {
return;
}
bubbleItems.value[lastIndex] = {
...bubbleItems.value[lastIndex]!,
...patch,
};
};
const finalizeLastBubbleItem = () => {
updateLastBubbleItem({
loading: false,
typing: false,
});
};
const stopSse = () => {
sseClient.abort();
sending.value = false;
const lastBubbleItem = bubbleItems.value[bubbleItems.value.length - 1];
if (lastBubbleItem) {
bubbleItems.value[bubbleItems.value.length - 1] = {
...lastBubbleItem,
content: lastBubbleItem.content,
loading: false,
typing: false,
};
}
finalizeLastBubbleItem();
};
const clearSenderFiles = () => {
files.value = [];
@@ -198,6 +256,8 @@ const handleSubmit = async (refreshContent: string) => {
// finish
if (event === 'done') {
sending.value = false;
finalizeLastBubbleItem();
stopThinking();
return;
}
if (!message.data) {
@@ -215,7 +275,7 @@ const handleSubmit = async (refreshContent: string) => {
...lastBubbleItem,
content: errorMessage,
loading: false,
typing: true,
typing: false,
};
return;
}
@@ -301,19 +361,13 @@ const handleSubmit = async (refreshContent: string) => {
},
onFinished() {
sending.value = false;
const lastIndex = bubbleItems.value.length - 1;
if (lastIndex) {
bubbleItems.value[lastIndex] = {
...bubbleItems.value[lastIndex]!,
loading: false,
};
}
finalizeLastBubbleItem();
stopThinking();
},
onError(err) {
console.error(err);
sending.value = false;
finalizeLastBubbleItem();
},
});
};
@@ -389,6 +443,14 @@ const handleCopy = (content: string) => {
const handleRefresh = () => {
handleSubmit(lastUserMessage.value);
};
const scrollToBottom = () => {
bubbleListRef.value?.scrollToBottom();
if (!bubbleListRef.value && bubbleListScrollElement.value) {
bubbleListScrollElement.value.scrollTop =
bubbleListScrollElement.value.scrollHeight;
}
showBackToBottomButton.value = false;
};
const showHeaderFlog = ref(false);
function openCloseHeader() {
if (showHeaderFlog.value) {
@@ -406,6 +468,23 @@ function handlePasteFile(_: any, fileList: FileList) {
senderRef.value?.openHeader();
files.value = [...fileList];
}
watch(
() => [localeConversationId.value, bubbleItems.value.length],
() => {
nextTick(() => {
bindBubbleListScroll();
});
},
{ flush: 'post' },
);
onBeforeUnmount(() => {
if (bubbleListScrollElement.value) {
bubbleListScrollElement.value.removeEventListener(
'scroll',
handleBubbleListScroll,
);
}
});
</script>
<template>
@@ -421,11 +500,14 @@ function handlePasteFile(_: any, fileList: FileList) {
<!-- 对话列表 -->
<div
v-if="localeConversationId || bubbleItems.length > 0"
ref="messageContainerRef"
class="message-container w-full flex-1 overflow-hidden"
>
<ElBubbleList
ref="bubbleListRef"
class="!h-full"
max-height="none"
:show-back-button="false"
:list="bubbleItems"
@complete="handleComplete"
>
@@ -551,6 +633,16 @@ function handlePasteFile(_: any, fileList: FileList) {
</ElSpace>
</template>
</ElBubbleList>
<button
v-if="showBackToBottomButton"
type="button"
class="chat-back-to-bottom-btn"
@click="scrollToBottom"
>
<ElIcon size="16">
<ArrowDownBold />
</ElIcon>
</button>
</div>
<!-- 新对话显示bot信息 -->
@@ -638,6 +730,7 @@ function handlePasteFile(_: any, fileList: FileList) {
}
.message-container {
position: relative;
padding: 8px;
background-color: var(--bot-chat-message-container);
border-radius: 8px;
@@ -647,6 +740,30 @@ function handlePasteFile(_: any, fileList: FileList) {
border: 1px solid hsl(var(--border));
}
.chat-back-to-bottom-btn {
position: absolute;
right: 16px;
bottom: 16px;
z-index: 2;
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
color: var(--el-text-color-regular);
cursor: pointer;
background: var(--el-bg-color-overlay);
border: 1px solid var(--el-border-color-light);
border-radius: 999px;
box-shadow: 0 6px 10px rgba(0, 0, 0, 0.08);
transition: all 0.2s ease;
}
.chat-back-to-bottom-btn:hover {
color: var(--el-color-primary);
transform: translateY(-1px);
}
:deep(.el-bubble-content-wrapper .el-bubble-content-filled[data-v-a52d8fe0]) {
background-color: var(--bot-chat-message-item-back);
}