fix: 修复聊天预览滚动抽搐与键盘事件类型
任务: 解决问答预览页滚动抽搐并补齐类型检查报错\n\n- 在 chat 组件中改为自定义回到底部按钮,避免内置按钮导致滚动抖动\n- 统一收口流式结束状态,结束时关闭 typing/loading\n- 修复 FaqEditDialog 的 keydown 参数类型,兼容 Event | KeyboardEvent
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -101,8 +101,12 @@ const focusAnswerEditor = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleQuestionKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Tab' && !event.shiftKey) {
|
||||
const handleQuestionKeydown = (event: Event | KeyboardEvent) => {
|
||||
if (
|
||||
event instanceof KeyboardEvent &&
|
||||
event.key === 'Tab' &&
|
||||
!event.shiftKey
|
||||
) {
|
||||
event.preventDefault();
|
||||
focusAnswerEditor();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user