Compare commits
1 Commits
ai-agent
...
63490484e9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63490484e9 |
340
Yi.Ai.Vue3/src/components/ChatMessageList/index.vue
Normal file
340
Yi.Ai.Vue3/src/components/ChatMessageList/index.vue
Normal file
@@ -0,0 +1,340 @@
|
||||
<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, watch } 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;
|
||||
|
||||
/**
|
||||
* 检查是否滚动到底部
|
||||
* 允许一定的误差范围(5px)
|
||||
*/
|
||||
function isScrolledToBottom(): boolean {
|
||||
if (!scrollContainer.value) return false;
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollContainer.value;
|
||||
return scrollHeight - scrollTop - clientHeight < 5;
|
||||
}
|
||||
|
||||
/**
|
||||
* 平滑滚动到底部(不使用 smooth 行为,避免跳动)
|
||||
*/
|
||||
function scrollToBottomSmooth() {
|
||||
if (!scrollContainer.value || !autoScroll.value) return;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (scrollContainer.value) {
|
||||
scrollContainer.value.scrollTop = scrollContainer.value.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 滚动到底部(立即)
|
||||
*/
|
||||
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(() => {
|
||||
// 如果启用了自动滚动且用户没有在滚动,则滚动到底部
|
||||
if (autoScroll.value && !isUserScrolling.value) {
|
||||
scrollToBottomSmooth();
|
||||
}
|
||||
});
|
||||
|
||||
// 监听子元素的变化和文本内容的变化
|
||||
mutationObserver.observe(contentElement, {
|
||||
childList: true, // 监听子元素的添加/删除
|
||||
subtree: true, // 监听所有后代元素
|
||||
characterData: true, // 监听文本内容变化
|
||||
attributes: false // 不监听属性变化
|
||||
});
|
||||
}
|
||||
|
||||
// 监听列表变化
|
||||
watch(
|
||||
() => props.list,
|
||||
() => {
|
||||
// 列表变化时,如果启用了自动滚动,滚动到底部
|
||||
if (autoScroll.value) {
|
||||
nextTick(() => {
|
||||
scrollToBottomSmooth();
|
||||
});
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// 组件挂载时初始化
|
||||
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 (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>
|
||||
@@ -2,7 +2,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { AnyObject } from 'typescript-api-pro';
|
||||
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 { ThinkingStatus } from 'vue-element-plus-x/types/Thinking';
|
||||
import { ArrowLeftBold, ArrowRightBold, Document, Loading } from '@element-plus/icons-vue';
|
||||
@@ -14,6 +13,7 @@ import { useRoute } from 'vue-router';
|
||||
import { send } from '@/api';
|
||||
import ModelSelect from '@/components/ModelSelect/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 { useChatStore } from '@/stores/modules/chat';
|
||||
import { useFilesStore } from '@/stores/modules/files';
|
||||
@@ -50,7 +50,7 @@ const avatar = computed(() => {
|
||||
const inputValue = ref('');
|
||||
const senderRef = ref<InstanceType<typeof Sender> | null>(null);
|
||||
const bubbleItems = ref<MessageItem[]>([]);
|
||||
const bubbleListRef = ref<BubbleListInstance | null>(null);
|
||||
const bubbleListRef = ref<InstanceType<typeof ChatMessageList> | null>(null);
|
||||
const isSending = ref(false);
|
||||
|
||||
const { stream, loading: isLoading, cancel } = useHookFetch({
|
||||
@@ -446,7 +446,7 @@ function handleImagePreview(url: string) {
|
||||
<template>
|
||||
<div class="chat-with-id-container">
|
||||
<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 }">
|
||||
<Thinking
|
||||
v-if="item.reasoning_content" v-model="item.thinlCollapse" :content="item.reasoning_content"
|
||||
@@ -503,7 +503,7 @@ function handleImagePreview(url: string) {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</BubbleList>
|
||||
</ChatMessageList>
|
||||
|
||||
<Sender
|
||||
ref="senderRef" v-model="inputValue" class="chat-defaul-sender" data-tour="chat-sender" :auto-size="{
|
||||
@@ -577,10 +577,10 @@ function handleImagePreview(url: string) {
|
||||
}
|
||||
}
|
||||
:deep() {
|
||||
.el-bubble-list {
|
||||
.chat-message-list {
|
||||
padding-top: 24px;
|
||||
}
|
||||
.el-bubble {
|
||||
.chat-message-item {
|
||||
padding: 0 12px;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
1
Yi.Ai.Vue3/types/components.d.ts
vendored
1
Yi.Ai.Vue3/types/components.d.ts
vendored
@@ -11,6 +11,7 @@ declare module 'vue' {
|
||||
AccountPassword: typeof import('./../src/components/LoginDialog/components/FormLogin/AccountPassword.vue')['default']
|
||||
APIKeyManagement: typeof import('./../src/components/userPersonalCenter/components/APIKeyManagement.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']
|
||||
DeepThinking: typeof import('./../src/components/DeepThinking/index.vue')['default']
|
||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||
|
||||
Reference in New Issue
Block a user