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">
|
<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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user