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

View File

@@ -101,8 +101,12 @@ const focusAnswerEditor = () => {
}); });
}; };
const handleQuestionKeydown = (event: KeyboardEvent) => { const handleQuestionKeydown = (event: Event | KeyboardEvent) => {
if (event.key === 'Tab' && !event.shiftKey) { if (
event instanceof KeyboardEvent &&
event.key === 'Tab' &&
!event.shiftKey
) {
event.preventDefault(); event.preventDefault();
focusAnswerEditor(); focusAnswerEditor();
} }