3 Commits

Author SHA1 Message Date
chenchun
1eca2cb273 perf: 优化版本 2025-12-17 16:50:00 +08:00
chenchun
28452b4c07 Merge branch 'ai-hub' into bubblelist 2025-12-17 16:25:37 +08:00
chenchun
63490484e9 feat: 自定义列表 2025-12-17 15:42:13 +08:00
3 changed files with 362 additions and 6 deletions

View File

@@ -0,0 +1,355 @@
<template>
<div class="chat-message-list" :style="{ maxHeight }">
<div ref="scrollContainer" class="chat-message-list__container">
<div class="chat-message-list__content">
<div
v-for="(item, index) in list"
:key="item.key || index"
class="chat-message-item"
:class="{
'chat-message-item--user': item.placement === 'end',
'chat-message-item--assistant': item.placement === 'start'
}"
>
<!-- 消息头部 -->
<div v-if="$slots.header" class="chat-message-item__header">
<slot name="header" :item="item" :index="index" />
</div>
<!-- 消息主体 -->
<div class="chat-message-item__body">
<!-- 头像 -->
<div v-if="item.avatar" class="chat-message-item__avatar">
<img
:src="item.avatar"
:style="{
width: item.avatarSize || '40px',
height: item.avatarSize || '40px'
}"
alt="avatar"
>
</div>
<!-- 消息内容 -->
<div
class="chat-message-item__content"
:class="{
'chat-message-item__content--no-style': item.noStyle
}"
>
<slot name="content" :item="item" :index="index">
{{ item.content }}
</slot>
</div>
</div>
<!-- 消息底部 -->
<div v-if="$slots.footer" class="chat-message-item__footer">
<slot name="footer" :item="item" :index="index" />
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, nextTick, onMounted, onUnmounted } from 'vue';
interface MessageItem {
key?: number;
avatar?: string;
avatarSize?: string;
placement?: 'start' | 'end';
content?: string;
noStyle?: boolean;
[key: string]: any;
}
interface Props {
list: MessageItem[];
maxHeight?: string;
}
const props = withDefaults(defineProps<Props>(), {
list: () => [],
maxHeight: '100%'
});
const scrollContainer = ref<HTMLDivElement | null>(null);
const autoScroll = ref(true); // 是否自动滚动到底部
const isUserScrolling = ref(false); // 用户是否正在手动滚动
let scrollTimeout: any = null;
let mutationObserver: MutationObserver | null = null;
let scrollRAF: number | null = null; // 用于存储 requestAnimationFrame ID
/**
* 检查是否滚动到底部
* 允许一定的误差范围5px
*/
function isScrolledToBottom(): boolean {
if (!scrollContainer.value) return false;
const { scrollTop, scrollHeight, clientHeight } = scrollContainer.value;
return scrollHeight - scrollTop - clientHeight < 5;
}
/**
* 立即滚动到底部(用于内容快速变化时)
*/
function scrollToBottomImmediate() {
if (!scrollContainer.value || !autoScroll.value) return;
scrollContainer.value.scrollTop = scrollContainer.value.scrollHeight;
}
/**
* 平滑滚动到底部(使用 RAF 避免过度调用)
*/
function scrollToBottomSmooth() {
if (!scrollContainer.value || !autoScroll.value) return;
// 取消之前的 RAF避免重复调用
if (scrollRAF !== null) {
cancelAnimationFrame(scrollRAF);
}
scrollRAF = requestAnimationFrame(() => {
if (scrollContainer.value) {
scrollContainer.value.scrollTop = scrollContainer.value.scrollHeight;
}
scrollRAF = null;
});
}
/**
* 滚动到底部(立即)
*/
function scrollToBottom() {
nextTick(() => {
if (scrollContainer.value) {
autoScroll.value = true; // 重新启用自动滚动
scrollContainer.value.scrollTop = scrollContainer.value.scrollHeight;
}
});
}
/**
* 处理滚动事件
* 检测用户是否手动滚动离开底部
*/
function handleScroll() {
if (!scrollContainer.value) return;
// 清除之前的定时器
if (scrollTimeout) {
clearTimeout(scrollTimeout);
}
// 标记用户正在滚动
isUserScrolling.value = true;
// 检查是否在底部
const atBottom = isScrolledToBottom();
if (atBottom) {
// 如果滚动到底部,启用自动滚动
autoScroll.value = true;
} else {
// 如果不在底部,禁用自动滚动
autoScroll.value = false;
}
// 300ms 后标记用户停止滚动
scrollTimeout = setTimeout(() => {
isUserScrolling.value = false;
}, 300);
}
/**
* 监听内容变化,自动滚动到底部
*/
function observeContentChanges() {
if (!scrollContainer.value) return;
const contentElement = scrollContainer.value.querySelector('.chat-message-list__content');
if (!contentElement) return;
// 创建 MutationObserver 监听内容变化
mutationObserver = new MutationObserver((mutations) => {
// 如果启用了自动滚动且用户没有在滚动,则滚动到底部
if (autoScroll.value && !isUserScrolling.value) {
// 检查是否有文本内容变化characterData这通常是 SSE 流式输出
const hasCharacterDataChange = mutations.some(
mutation => mutation.type === 'characterData'
);
if (hasCharacterDataChange) {
// SSE 流式输出时使用立即滚动,避免跳动
scrollToBottomImmediate();
} else {
// 其他情况使用平滑滚动
scrollToBottomSmooth();
}
}
});
// 监听子元素的变化和文本内容的变化
mutationObserver.observe(contentElement, {
childList: true, // 监听子元素的添加/删除
subtree: true, // 监听所有后代元素
characterData: true, // 监听文本内容变化
attributes: false // 不监听属性变化
});
}
// 组件挂载时初始化
onMounted(() => {
if (scrollContainer.value) {
scrollContainer.value.addEventListener('scroll', handleScroll, { passive: true });
}
// 初始化时滚动到底部
nextTick(() => {
scrollToBottom();
// 开始监听内容变化
observeContentChanges();
});
});
// 组件卸载时清理
onUnmounted(() => {
if (scrollContainer.value) {
scrollContainer.value.removeEventListener('scroll', handleScroll);
}
if (scrollTimeout) {
clearTimeout(scrollTimeout);
}
if (scrollRAF !== null) {
cancelAnimationFrame(scrollRAF);
}
if (mutationObserver) {
mutationObserver.disconnect();
}
});
// 暴露方法给父组件调用
defineExpose({
scrollToBottom
});
</script>
<style scoped lang="scss">
.chat-message-list {
width: 100%;
height: 100%;
overflow: hidden;
&__container {
width: 100%;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
/* 美化滚动条 */
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
&:hover {
background: rgba(0, 0, 0, 0.3);
}
}
}
&__content {
width: 100%;
min-height: 100%;
display: flex;
flex-direction: column;
}
}
.chat-message-item {
display: flex;
flex-direction: column;
width: 100%;
margin-bottom: 16px;
&__header {
width: 100%;
margin-bottom: 8px;
}
&__body {
display: flex;
gap: 12px;
width: 100%;
}
&__avatar {
flex-shrink: 0;
img {
border-radius: 50%;
object-fit: cover;
}
}
&__content {
flex: 1;
min-width: 0;
padding: 12px 16px;
background-color: #f5f5f5;
border-radius: 12px;
word-wrap: break-word;
overflow-wrap: break-word;
&--no-style {
background-color: transparent;
padding: 0;
}
}
&__footer {
width: 100%;
margin-top: 8px;
padding-left: 52px; /* 头像宽度 + gap */
}
/* 用户消息样式 */
&--user {
.chat-message-item__body {
flex-direction: row-reverse;
}
.chat-message-item__content {
background-color: #409eff;
color: white;
}
.chat-message-item__footer {
padding-left: 0;
padding-right: 52px;
text-align: right;
}
}
/* 助手消息样式 */
&--assistant {
.chat-message-item__body {
flex-direction: row;
}
.chat-message-item__content {
background-color: #f5f5f5;
}
}
}
</style>

View File

@@ -2,7 +2,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { AnyObject } from 'typescript-api-pro'; import type { AnyObject } from 'typescript-api-pro';
import type { BubbleProps } from 'vue-element-plus-x/types/Bubble'; import type { BubbleProps } from 'vue-element-plus-x/types/Bubble';
import type { BubbleListInstance } from 'vue-element-plus-x/types/BubbleList';
import type { FilesCardProps } from 'vue-element-plus-x/types/FilesCard'; import type { FilesCardProps } from 'vue-element-plus-x/types/FilesCard';
import type { ThinkingStatus } from 'vue-element-plus-x/types/Thinking'; import type { ThinkingStatus } from 'vue-element-plus-x/types/Thinking';
import { ArrowLeftBold, ArrowRightBold, Document, Loading } from '@element-plus/icons-vue'; import { ArrowLeftBold, ArrowRightBold, Document, Loading } from '@element-plus/icons-vue';
@@ -14,6 +13,7 @@ import { useRoute } from 'vue-router';
import { send } from '@/api'; import { send } from '@/api';
import ModelSelect from '@/components/ModelSelect/index.vue'; import ModelSelect from '@/components/ModelSelect/index.vue';
import YMarkdown from '@/vue-element-plus-y/components/XMarkdown/index.vue'; import YMarkdown from '@/vue-element-plus-y/components/XMarkdown/index.vue';
import ChatMessageList from '@/components/ChatMessageList/index.vue';
import { useGuideTourStore } from '@/stores'; import { useGuideTourStore } from '@/stores';
import { useChatStore } from '@/stores/modules/chat'; import { useChatStore } from '@/stores/modules/chat';
import { useFilesStore } from '@/stores/modules/files'; import { useFilesStore } from '@/stores/modules/files';
@@ -50,7 +50,7 @@ const avatar = computed(() => {
const inputValue = ref(''); const inputValue = ref('');
const senderRef = ref<InstanceType<typeof Sender> | null>(null); const senderRef = ref<InstanceType<typeof Sender> | null>(null);
const bubbleItems = ref<MessageItem[]>([]); const bubbleItems = ref<MessageItem[]>([]);
const bubbleListRef = ref<BubbleListInstance | null>(null); const bubbleListRef = ref<InstanceType<typeof ChatMessageList> | null>(null);
const isSending = ref(false); const isSending = ref(false);
const { stream, loading: isLoading, cancel } = useHookFetch({ const { stream, loading: isLoading, cancel } = useHookFetch({
@@ -446,7 +446,7 @@ function handleImagePreview(url: string) {
<template> <template>
<div class="chat-with-id-container"> <div class="chat-with-id-container">
<div class="chat-warp"> <div class="chat-warp">
<BubbleList ref="bubbleListRef" :list="bubbleItems" max-height="calc(100vh - 240px)"> <ChatMessageList ref="bubbleListRef" :list="bubbleItems" max-height="calc(100vh - 240px)">
<template #header="{ item }"> <template #header="{ item }">
<Thinking <Thinking
v-if="item.reasoning_content" v-model="item.thinlCollapse" :content="item.reasoning_content" v-if="item.reasoning_content" v-model="item.thinlCollapse" :content="item.reasoning_content"
@@ -503,7 +503,7 @@ function handleImagePreview(url: string) {
</div> </div>
</div> </div>
</template> </template>
</BubbleList> </ChatMessageList>
<Sender <Sender
ref="senderRef" v-model="inputValue" class="chat-defaul-sender" data-tour="chat-sender" :auto-size="{ ref="senderRef" v-model="inputValue" class="chat-defaul-sender" data-tour="chat-sender" :auto-size="{
@@ -577,10 +577,10 @@ function handleImagePreview(url: string) {
} }
} }
:deep() { :deep() {
.el-bubble-list { .chat-message-list {
padding-top: 24px; padding-top: 24px;
} }
.el-bubble { .chat-message-item {
padding: 0 12px; padding: 0 12px;
padding-bottom: 24px; padding-bottom: 24px;
} }

View File

@@ -11,6 +11,7 @@ declare module 'vue' {
AccountPassword: typeof import('./../src/components/LoginDialog/components/FormLogin/AccountPassword.vue')['default'] AccountPassword: typeof import('./../src/components/LoginDialog/components/FormLogin/AccountPassword.vue')['default']
APIKeyManagement: typeof import('./../src/components/userPersonalCenter/components/APIKeyManagement.vue')['default'] APIKeyManagement: typeof import('./../src/components/userPersonalCenter/components/APIKeyManagement.vue')['default']
CardFlipActivity: typeof import('./../src/components/userPersonalCenter/components/CardFlipActivity.vue')['default'] CardFlipActivity: typeof import('./../src/components/userPersonalCenter/components/CardFlipActivity.vue')['default']
ChatMessageList: typeof import('./../src/components/ChatMessageList/index.vue')['default']
DailyTask: typeof import('./../src/components/userPersonalCenter/components/DailyTask.vue')['default'] DailyTask: typeof import('./../src/components/userPersonalCenter/components/DailyTask.vue')['default']
DeepThinking: typeof import('./../src/components/DeepThinking/index.vue')['default'] DeepThinking: typeof import('./../src/components/DeepThinking/index.vue')['default']
ElAlert: typeof import('element-plus/es')['ElAlert'] ElAlert: typeof import('element-plus/es')['ElAlert']