📌 Warning
1.1.6 版本 继承打字器 雾化 效果。新增 滚动底部按钮,仿 豆包🔥。新增 鼠标悬停展示滚动条,增强交互体验。请及时更新尝试
🐵 此温馨提示更新时间:2025-04-13
💡 Tip
另: 新版本的自动滚动,在 list 长度变化时,自动滚动。但是 向上滚动滚动条后,需要手动调用 scrollToBottom 方法,以再次实现自动滚动。或者 滚动条滚动到底部后,会重新触发自动滚动。
和原来逻辑一样, 升级无需任何心理负担。
介绍
BubbleList 依赖于 Bubble 组件,用于展示一组对话气泡列表。该组件支持设置 列表最大高度,具备 自动滚动 功能。同时,它还提供了多种 控制滚动 的方法,使用者 可以轻松调用,性能强大,无需任何开发心理负担。
代码演示
基本使用



<script setup lang="ts">
import type {
BubbleListItemProps,
BubbleListProps
} from 'vue-element-plus-x/types/BubbleList';
type listType = BubbleListItemProps & {
key: number;
role: 'user' | 'ai';
isMarkdown?: boolean;
typing?: boolean;
isFog?: boolean;
};
// 示例调用
const list: BubbleListProps<listType>['list'] = generateFakeItems(5);
function generateFakeItems(count: number): listType[] {
const messages: listType[] = [];
for (let i = 0; i < count; i++) {
const role = i % 2 === 0 ? 'ai' : 'user';
const placement = role === 'ai' ? 'start' : 'end';
const key = i + 1;
const content =
role === 'ai'
? '💖 感谢使用 Element Plus X ! 你的支持,是我们开源的最强动力 ~'.repeat(
5
)
: `哈哈哈,让我试试`;
const loading = false;
const shape = 'corner';
const variant = role === 'ai' ? 'filled' : 'outlined';
const isMarkdown = false;
const typing = role === 'ai' ? i === count - 1 : false;
const avatar =
role === 'ai'
? 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png'
: 'https://avatars.githubusercontent.com/u/76239030?v=4';
messages.push({
key, // 唯一标识
role, // user | ai 自行更据模型定义
placement, // start | end 气泡位置
content, // 消息内容 流式接受的时候,只需要改这个值即可
loading, // 当前气泡的加载状态
shape, // 气泡的形状
variant, // 气泡的样式
isMarkdown, // 是否渲染为 markdown
typing, // 是否开启打字器效果 该属性不会和流式接受冲突
isFog: role === 'ai', // 是否开启打字雾化效果,该效果 v1.1.6 新增,且在 typing 为 true 时生效,该效果会覆盖 typing 的 suffix 属性
avatar,
avatarSize: '24px', // 头像占位大小
avatarGap: '12px' // 头像与气泡之间的距离
});
}
return messages;
}
</script>
<template>
<BubbleList :list="list" max-height="350px" />
</template>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
自定义列表



🍋 通过 #avatar、#header、#content、#loading、#footer 插槽,可以更灵活的控制气泡列表的渲染
<script setup lang="ts">
import type {
BubbleListItemProps,
BubbleListProps
} from 'vue-element-plus-x/types/BubbleList';
import { DocumentCopy, Refresh, Search, Star } from '@element-plus/icons-vue';
type listType = BubbleListItemProps & {
key: number;
role: 'user' | 'ai';
};
// 示例调用
const bubbleItems = ref<BubbleListProps<listType>['list']>(
generateFakeItems(5)
);
const avatar = ref('https://avatars.githubusercontent.com/u/76239030?v=4');
const avartAi = ref(
'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png'
);
const switchValue = ref(false);
const loading = ref(false);
function generateFakeItems(count: number): listType[] {
const messages: listType[] = [];
for (let i = 0; i < count; i++) {
const role = i % 2 === 0 ? 'ai' : 'user';
const placement = role === 'ai' ? 'start' : 'end';
const key = i + 1;
messages.push({
key,
role,
placement,
noStyle: true // 如果你不想用默认的气泡样式
});
}
return messages;
}
// 设置某个 item 的 loading
function setLoading(loading: boolean) {
bubbleItems.value[bubbleItems.value.length - 1].loading = loading;
bubbleItems.value[bubbleItems.value.length - 2].loading = loading;
}
</script>
<template>
<div style="display: flex; flex-direction: column; gap: 12px">
<div style="display: flex; gap: 12px">
<span>动态设置内容 <el-switch v-model="switchValue" /></span>
<span>自定义 loading
<el-switch
v-model="loading"
@change="(value: any) => setLoading(value as boolean)"
/></span>
</div>
<BubbleList :list="bubbleItems" max-height="350px">
<!-- 自定义头像 -->
<template #avatar="{ item }">
<div class="avatar-wrapper">
<img :src="item.role === 'ai' ? avartAi : avatar" alt="avatar">
</div>
</template>
<!-- 自定义头部 -->
<template #header="{ item }">
<div class="header-wrapper">
<div class="header-name">
{{ item.role === 'ai' ? 'Element Plus X 🍧' : '🧁 用户' }}
</div>
</div>
</template>
<!-- 自定义气泡内容 -->
<template #content="{ item }">
<div class="content-wrapper">
<div class="content-text">
{{
item.role === 'ai'
? `${switchValue ? `#ai-${item.key}:` : ''} 💖 感谢使用 Element Plus X ! 你的支持,是我们开源的最强动力 ~`
: `${switchValue ? `#user-${item.key}:` : ''}哈哈哈,让我试试`
}}
</div>
</div>
</template>
<!-- 自定义底部 -->
<template #footer="{ item }">
<div class="footer-wrapper">
<div class="footer-container">
<el-button type="info" :icon="Refresh" size="small" circle />
<el-button type="success" :icon="Search" size="small" circle />
<el-button type="warning" :icon="Star" size="small" circle />
<el-button
color="#626aef"
:icon="DocumentCopy"
size="small"
circle
/>
</div>
<div class="footer-time">
{{ item.role === 'ai' ? '下午 2:32' : '下午 2:33' }}
</div>
</div>
</template>
<!-- 自定义 loading -->
<template #loading="{ item }">
<div class="loading-container">
<span>#{{ item.role }}-{{ item.key }}:</span>
<span>我</span>
<span>是</span>
<span>自</span>
<span>定</span>
<span>义</span>
<span>加</span>
<span>载</span>
<span>内</span>
<span>容</span>
<span>哦</span>
<span>~</span>
</div>
</template>
</BubbleList>
</div>
</template>
<style scoped lang="less">
.avatar-wrapper {
width: 40px;
height: 40px;
img {
width: 100%;
height: 100%;
border-radius: 50%;
}
}
.header-wrapper {
.header-name {
font-size: 14px;
color: #979797;
}
}
.content-wrapper {
.content-text {
font-size: 14px;
color: #333;
padding: 12px;
background: linear-gradient(to right, #fdfcfb 0%, #ffd1ab 100%);
border-radius: 15px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
}
.footer-wrapper {
display: flex;
align-items: center;
gap: 10px;
.footer-time {
font-size: 12px;
margin-top: 3px;
}
}
.footer-container {
:deep(.el-button + .el-button) {
margin-left: 8px;
}
}
.loading-container {
font-size: 14px;
color: #333;
padding: 12px;
background: linear-gradient(to right, #fdfcfb 0%, #ffd1ab 100%);
border-radius: 15px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.loading-container span {
display: inline-block;
margin-left: 8px;
}
@keyframes bounce {
0%,
100% {
transform: translateY(5px);
}
50% {
transform: translateY(-5px);
}
}
.loading-container span:nth-child(4n) {
animation: bounce 1.2s ease infinite;
}
.loading-container span:nth-child(4n + 1) {
animation: bounce 1.2s ease infinite;
animation-delay: 0.3s;
}
.loading-container span:nth-child(4n + 2) {
animation: bounce 1.2s ease infinite;
animation-delay: 0.6s;
}
.loading-container span:nth-child(4n + 3) {
animation: bounce 1.2s ease infinite;
animation-delay: 0.9s;
}
</style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
自动滚动、指定滚动位置

🍑 你可以轻松使用 bubbleListRef 组件实例方法,控制 滚动到指定索引。
🍒 组件实例方法
scrollToTop():滚动到顶部scrollToBottom():滚动到底部scrollToBubble(index: number):滚动到指定索引
<script setup lang="ts">
import type {
BubbleListItemProps,
BubbleListProps
} from 'vue-element-plus-x/types/BubbleList';
type listType = BubbleListItemProps & {
key: number;
role: 'user' | 'ai';
};
const bubbleItems = ref<BubbleListProps<listType>['list']>(
generateFakeItems(2)
);
function generateFakeItems(count: number): listType[] {
const messages: listType[] = [];
for (let i = 0; i < count; i++) {
const role = i % 2 === 0 ? 'ai' : 'user';
const placement = role === 'ai' ? 'start' : 'end';
const key = i + 1;
const content =
role === 'ai'
? '💖 感谢使用 Element Plus X ! 你的支持,是我们开源的最强动力 ~'
: `哈哈哈,让我试试`;
const loading = false;
const shape = 'corner';
const variant = role === 'ai' ? 'filled' : 'outlined';
const avatar =
role === 'ai'
? 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png'
: 'https://avatars.githubusercontent.com/u/76239030?v=4';
messages.push({
key,
role,
placement,
content,
loading,
shape,
variant,
avatar,
avatarSize: '32px'
});
}
return messages;
}
const bubbleListRef = ref();
const num = ref(0);
function addMessage() {
const i = bubbleItems.value.length;
const isUser = !!(i % 2);
const content = isUser
? '哈哈哈,让我试试'
: '💖 感谢使用 Element Plus X ! 你的支持,是我们开源的最强动力 ~'.repeat(5);
const shape = 'corner';
const variant = !isUser ? 'filled' : 'outlined';
const placement = isUser ? 'end' : 'start';
const avatar = isUser
? 'https://avatars.githubusercontent.com/u/76239030?v=4'
: 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png';
const obj = {
key: i,
role: isUser ? 'user' : 'ai',
content,
placement,
avatar,
shape,
variant,
avatarSize: '32px'
};
bubbleItems.value.push(obj as listType);
scrollBottom();
}
function clearMessage() {
bubbleItems.value = [];
}
function scrollToTop() {
bubbleListRef.value.scrollToTop();
}
function scrollBottom() {
bubbleListRef.value.scrollToBottom();
}
function scrollToBubble() {
bubbleListRef.value.scrollToBubble(num.value);
}
onMounted(() => {
setTimeout(() => {
bubbleItems.value.map(item => {
item.loading = false;
return item;
});
}, 3000);
});
</script>
<template>
<div class="component-container">
<div class="top-wrap">
<div class="btn-list">
<el-button type="primary" plain @click="addMessage">
添加对话
</el-button>
<el-button type="danger" plain @click="clearMessage">
清空对话列表
</el-button>
<el-button type="primary" plain @click="scrollToTop">
滚动到顶部
</el-button>
<el-button type="primary" plain @click="scrollBottom">
滚动到底部
</el-button>
</div>
<div class="btn-list">
<el-input-number
v-model="num"
:min="0"
:max="10"
controls-position="right"
/>
<el-button type="primary" plain @click="scrollToBubble">
滚动第{{ num }}个气泡框
</el-button>
</div>
</div>
<div class="component-1">
<BubbleList ref="bubbleListRef" :list="bubbleItems" max-height="350px" />
</div>
</div>
</template>
<style scoped lang="less">
.component-container {
padding: 12px;
.btn-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.top-wrap {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 24px;
}
}
</style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
返回顶部按钮






- 内置回到底部按钮,仿
豆包。 - 鼠标悬停时,会出现滚动条
- 和内置自动滚动不冲突,请放心使用
💌 Info
滚动条控制属性
alwaysShowScrollbar属性控制是否一直显示滚动条,默认为false。
底部按钮定制化属性
- 你可以通过
backButtonThreshold属性来设置回到底部按钮的阈值,默认为80,即当滚动条滚动到距离底部80px时,才会出现回到底部按钮。 showBackButton属性控制是否显示回到底部按钮,默认为true。btnLoading属性控制是否显示加载中的状态,默认为true。btnColor属性控制回到底部按钮的颜色,默认为#409EFF。backButtonPosition属性控制回到底部按钮的位置,默认为{ bottom: '20px', left: 'calc(50% - 19px)' }可以用%来控制,如{ bottom: '10%', left: 'calc(50% - 19px)' }。btnIconSize属性控制回到底部按钮的图标大小,默认为24。
<script setup lang="ts">
import type {
BubbleListItemProps,
BubbleListProps
} from 'vue-element-plus-x/types/BubbleList';
type listType = BubbleListItemProps & {
key: number;
role: 'user' | 'ai';
isMarkdown?: boolean;
typing?: boolean;
isFog?: boolean;
};
// 示例调用
const list: BubbleListProps<listType>['list'] = generateFakeItems(12);
const alwaysShowScrollbar = ref(false);
const btnLoading = ref(true);
const bottomValue = ref(10);
const leftValue = ref(85);
const backButtonPosition = computed(() => {
return {
bottom: `${bottomValue.value}%`,
left: `${leftValue.value}%`
};
});
const btnColor = ref('#2D38FF');
const btnSize = ref(40);
function generateFakeItems(count: number): listType[] {
const messages: listType[] = [];
for (let i = 0; i < count; i++) {
const role = i % 2 === 0 ? 'ai' : 'user';
const placement = role === 'ai' ? 'start' : 'end';
const key = i + 1;
const content =
role === 'ai'
? '💖 感谢使用 Element Plus X ! 你的支持,是我们开源的最强动力 ~'.repeat(
8
)
: `哈哈哈,让我试试`;
const loading = false;
const shape = 'corner';
const variant = role === 'ai' ? 'filled' : 'outlined';
const isMarkdown = false;
const typing = role === 'ai' ? i === count - 1 : false;
const avatar =
role === 'ai'
? 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png'
: 'https://avatars.githubusercontent.com/u/76239030?v=4';
messages.push({
key, // 唯一标识
role, // user | ai 自行更据模型定义
placement, // start | end 气泡位置
content, // 消息内容 流式接受的时候,只需要改这个值即可,这里暂时没有用到流式数据
loading, // 当前气泡的加载状态
shape, // 气泡的形状
variant, // 气泡的样式
isMarkdown, // 是否渲染为 markdown
typing, // 是否开启打字器效果 该属性不会和流式接受冲突
isFog: role === 'ai', // 是否开启打字雾化效果,该效果 v1.1.6 新增,且在 typing 为 true 时生效,该效果会覆盖 typing 的 suffix 属性
avatar,
avatarSize: '24px', // 头像占位大小
avatarGap: '12px' // 头像与气泡之间的距离
});
}
return messages;
}
</script>
<template>
<div style="display: flex; flex-direction: column; gap: 24px">
<div
style="
display: flex;
gap: 5px;
border: 1px solid gray;
border-radius: 12px;
padding: 8px;
flex-direction: column;
"
>
<span
>滚动条显示:<el-switch
v-model="alwaysShowScrollbar"
inactive-text="鼠标悬停展示"
active-text="一直展示"
/></span>
<span
>底部按钮加载状态:<el-switch
v-model="btnLoading"
inactive-text="false"
active-text="true"
/></span>
<span>底部按钮颜色: <el-color-picker v-model="btnColor" /></span>
<span>底部按钮位</span>
<span>距离底部:<el-slider v-model="bottomValue" /></span>
<span>距离左边:<el-slider v-model="leftValue" /></span>
<span>底部按钮尺寸:<el-slider v-model="btnSize" /></span>
</div>
<BubbleList
:list="list"
max-height="350px"
:always-show-scrollbar="alwaysShowScrollbar"
:btn-color="btnColor"
:btn-loading="btnLoading"
:back-button-position="backButtonPosition"
:btn-icon-size="btnSize"
/>
</div>
</template>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
滚动完成事件
📌 Warning
极特殊情况才用的到,在流式输出中不适用,会快速触发打字结束事件。




该事件在 BubbleList 列表渲染完成时触发。
💌 Info
@complete事件返回index参数,表示完成的BubbleListItem的索引。- 从 v2.0.0 开始,
triggerIndices属性和TypewriterInstance参数已被移除。
<script setup lang="ts">
import type {
BubbleListItemProps,
BubbleListProps
} from 'vue-element-plus-x/types/BubbleList';
type listType = BubbleListItemProps & {
key: number;
role: 'user' | 'ai';
};
function generateFakeItems(count: number): listType[] {
const messages: listType[] = [];
for (let i = 0; i < count; i++) {
const role = i % 2 === 0 ? 'ai' : 'user';
const placement = role === 'ai' ? 'start' : 'end';
const key = i + 1;
const content =
role === 'ai'
? '💖 感谢使用 Element Plus X ! 你的支持,是我们开源的最强动力 ~'
: `哈哈哈,让我试试`;
const loading = false;
const shape = 'corner';
const variant = role === 'ai' ? 'filled' : 'outlined';
const avatar =
role === 'ai'
? 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png'
: 'https://avatars.githubusercontent.com/u/76239030?v=4';
messages.push({
key,
role,
placement,
content,
loading,
shape,
variant,
avatar,
avatarSize: '24px'
});
}
return messages;
}
const list = ref<BubbleListProps<listType>['list']>(generateFakeItems(7));
function onComplete(index: number) {
console.log('@complete', index);
}
</script>
<template>
<div style="display: flex; flex-direction: column; gap: 12px">
<BubbleList :list="list" max-height="350px" @complete="onComplete" />
</div>
</template>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
自定义主题
通过 ConfigProvider.themeOverrides 覆盖 BubbleList 的主题变量。完整变量表与可复制模板见:
通过 ConfigProvider.themeOverrides 覆盖 BubbleList 的 --elx-* 变量,并联动 Bubble 的主题变量,开关前后会有明显反差。
<script setup lang="ts">
import { computed, ref } from 'vue';
const enabled = ref(true);
const list = ref(
Array.from({ length: 18 }).map((_, i) => ({
content: `第 ${i + 1} 条消息:用于演示 BubbleList 的滚动与返回按钮。`,
placement: i % 2 === 0 ? 'start' : 'end'
}))
);
const themeOverrides = computed(() => {
if (!enabled.value) return {};
return {
common: {
'color-primary': '#f97316',
'border-color': 'rgba(249, 115, 22, 0.38)',
'fill-color': 'rgba(249, 115, 22, 0.10)',
'box-shadow': '0 18px 54px rgba(249, 115, 22, 0.22)'
},
components: {
BubbleList: {
'bubble-list-max-height': '260px',
'bubble-list-btn-size': '38px'
},
Bubble: {
'bubble-content-max-width': '420px',
'bubble-bg':
'linear-gradient(135deg, rgba(249, 115, 22, 0.16), rgba(245, 158, 11, 0.10))',
'bubble-border-color': 'rgba(249, 115, 22, 0.30)',
'bubble-text-color': 'rgba(15, 23, 42, 0.86)',
'bubble-radius': '18px',
'bubble-padding-y': '14px',
'bubble-padding-x': '18px',
'bubble-shadow': '0 18px 52px rgba(249, 115, 22, 0.18)',
'bubble-dot-color': '#f97316'
}
}
};
});
</script>
<template>
<div style="display: flex; flex-direction: column; gap: 12px">
<div
style="display: flex; align-items: center; justify-content: space-between"
>
<div>滚动列表,观察最大高度与返回按钮尺寸变化。</div>
<button
type="button"
style="
padding: 6px 10px;
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.12);
background: rgba(0, 0, 0, 0.02);
cursor: pointer;
"
@click="enabled = !enabled"
>
{{ enabled ? '关闭自定义主题' : '开启自定义主题' }}
</button>
</div>
<ConfigProvider apply-to="self" :theme-overrides="themeOverrides">
<div
style="
padding: 14px;
border-radius: 16px;
border: 1px solid var(--elx-border-color);
background:
radial-gradient(
1200px 280px at 0% 0%,
rgba(249, 115, 22, 0.22),
transparent 60%
),
radial-gradient(
900px 240px at 100% 20%,
rgba(245, 158, 11, 0.14),
transparent 55%
),
rgba(0, 0, 0, 0.02);
box-shadow: var(--elx-box-shadow);
"
>
<BubbleList :list="list" />
</div>
</ConfigProvider>
</div>
</template>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
与 x-markdown-vue 结合使用
从 v2.0.0 开始,组件库不再内置 XMarkdown / XMarkdownAsync。如需 Markdown 渲染,请使用独立包 x-markdown-vue,或查看专属文档:XMarkdown。
安装
pnpm add x-markdown-vue
pnpm add katex
pnpm add shiki shiki-stream2
3
💡 Tip
如果需要代码块语法高亮功能,请安装 shiki 和 shiki-stream。否则控制台可能会报错:Streaming highlighter initialization failed: Error: Failed to load shiki-stream module
基础用法
支持公式、代码块、任务列表的列表渲染。
<script setup lang="ts">
import 'katex/dist/katex.min.css';
import 'shiki';
import 'shiki-stream';
const MarkdownRenderer = shallowRef();
onMounted(async () => {
if (typeof window === 'undefined') return;
await import('x-markdown-vue/style');
const mod = await import('x-markdown-vue');
MarkdownRenderer.value = mod.MarkdownRenderer ?? mod.default ?? mod;
});
const list = ref([
{
key: '1',
content: `### 行内公式
1. 欧拉公式:$e^{i\\pi} + 1 = 0$
2. 二次方程求根公式:$x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$
3. 向量点积:$\\vec{a} \\cdot \\vec{b} = a_x b_x + a_y b_y + a_z b_z$
### []包裹公式
\\[ e^{i\\pi} + 1 = 0 \\]
\\[\\boxed{boxed包裹}\\]`,
placement: 'start' as const,
avatar: 'https://avatars.githubusercontent.com/u/76239030?s=40&v=4',
role: 'ai'
},
{
key: '2',
content: '请问有什么可以帮助您的?',
placement: 'end' as const,
avatar: 'https://avatars.githubusercontent.com/u/1?s=40&v=4',
role: 'user'
},
{
key: '3',
content: `### 块级公式与代码块
傅里叶变换:
$$
F(\\omega) = \\int_{-\\infty}^{\\infty} f(t) e^{-i\\omega t} dt
$$
矩阵乘法:
$$
\\begin{bmatrix}
a & b \\\\
c & d
\\end{bmatrix}
\\begin{bmatrix}
x \\\\
y
\\end{bmatrix}
=
\\begin{bmatrix}
ax + by \\\\
cx + dy
\\end{bmatrix}
$$
任务列表:
- [x] Add some task
- [ ] Do some task
\`\`\`typescript
const greeting: string = "Hello World";
console.log(greeting);
\`\`\``,
placement: 'start' as const,
avatar: 'https://avatars.githubusercontent.com/u/76239030?s=40&v=4',
role: 'ai'
}
]);
</script>
<template>
<BubbleList :list="list" max-height="500px">
<template #content="{ item }">
<div class="markdown-content-wrapper">
<component
:is="MarkdownRenderer"
v-if="MarkdownRenderer"
:markdown="item.content"
/>
</div>
</template>
</BubbleList>
</template>
<style scoped lang="scss">
.markdown-content-wrapper {
word-break: break-word;
color: #24292e;
:deep(h1),
:deep(h2),
:deep(h3),
:deep(h4),
:deep(h5),
:deep(h6) {
margin-top: 16px;
margin-bottom: 8px;
font-weight: 600;
line-height: 1.25;
&:first-child {
margin-top: 0;
}
}
:deep(p) {
margin-top: 0;
margin-bottom: 8px;
line-height: 1.6;
&:last-child {
margin-bottom: 0;
}
}
:deep(ul),
:deep(ol) {
padding-left: 20px;
margin-top: 0;
margin-bottom: 8px;
ul,
ol {
margin-top: 4px;
margin-bottom: 0;
}
}
:deep(ul) {
list-style-type: disc;
}
:deep(ol) {
list-style-type: decimal;
}
:deep(li) {
margin: 4px 0;
line-height: 1.6;
&.task-list-item {
list-style-type: none;
padding-left: 0;
display: flex;
align-items: flex-start;
margin-left: -20px;
input[type='checkbox'] {
margin: 5px 8px 0 0;
flex-shrink: 0;
}
}
}
:deep(a) {
color: #0366d6;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
:deep(code):not(pre code) {
background-color: rgba(27, 31, 35, 0.05);
padding: 0.2em 0.4em;
border-radius: 3px;
font-family:
ui-monospace,
SFMono-Regular,
SF Mono,
Menlo,
Consolas,
Liberation Mono,
monospace;
font-size: 85%;
}
:deep(blockquote) {
padding: 0 1em;
color: #6a737d;
border-left: 0.25em solid #dfe2e5;
margin: 0 0 8px 0;
}
:deep(hr) {
height: 0.25em;
padding: 0;
margin: 16px 0;
background-color: #e1e4e8;
border: 0;
}
:deep(table) {
display: block;
width: 100%;
overflow: auto;
margin-top: 0;
margin-bottom: 16px;
border-collapse: collapse;
th,
td {
padding: 6px 13px;
border: 1px solid #dfe2e5;
}
tr {
background-color: #fff;
border-top: 1px solid #c6cbd1;
&:nth-child(2n) {
background-color: #f6f8fa;
}
}
}
}
:deep(.x-md-code-block) {
pre {
background-color: #f6f8fa !important;
padding: 16px;
border-radius: 8px;
overflow: auto;
code {
font-family:
ui-monospace,
SFMono-Regular,
SF Mono,
Menlo,
Consolas,
Liberation Mono,
monospace;
font-size: 14px;
line-height: 1.5;
.line {
display: block;
min-height: 1rem;
}
}
}
}
</style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
流式渲染
通过设置 enable-animate 属性,可以实现打字机效果:
<template>
<BubbleList :list="list">
<template #content="{ item }">
<MarkdownRenderer :markdown="item.content" :enable-animate="true" />
</template>
</BubbleList>
</template>
<script setup>
import { ref } from 'vue';
import { BubbleList } from 'vue-element-plus-x';
import { MarkdownRenderer } from 'x-markdown-vue';
import 'x-markdown-vue/style';
const list = ref([
{ content: '', placement: 'start' } // 流式内容会动态更新
]);
</script>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
属性
| 属性名 | 类型 | 是否必填 | 默认值 | 说明 |
|---|---|---|---|---|
list | Array | 是 | 无 | 包含气泡信息的数组,每个元素为一个对象,包含 content、placement、loading、shape、variant 等 Bubble 属性,用于配置每个气泡的显示内容和样式。Markdown 场景可配合 #content 插槽渲染。 |
autoScroll | Boolean | 否 | true | 是否开启自动滚动。 |
maxHeight | String | 否 | - | 气泡列表list的最大高度(默认继承wrapper容器高度)。 |
alwaysShowScrollbar | Boolean | 否 | false | 是否一直显示滚动条,默认为 false。 |
backButtonThreshold | Number | 否 | 80 | 返回底部按钮显示阈值,当滚动条距离底部大于该值时,会显示返回底部按钮。 |
showBackButton | Boolean | 否 | true | 是否显示返回底部按钮,默认为 true。 |
backButtonPosition | { bottom: '20px', left: 'calc(50% - 19px)' } | 否 | { bottom: '20px', left: 'calc(50% - 19px)' } | 返回底部按钮的位置, 默认底部居中展示。 |
btnLoading | Boolean | 否 | true | 是否开启返回底部按钮 loading 状态,默认为 true。 |
btnColor | String | 否 | '#409EFF' | 返回底部按钮的颜色,默认为 '#409EFF'。 |
btnIconSize | Number | 否 | 24 | 返回底部按钮的图标大小,默认为 24px。 |
triggerIndices | 'only-last' | 'all' | number[] | 否 | 'only-last' | 触发complete事件的气泡 索引数组,默认为 'only-last'。 |
事件
| 事件名 | 参数 | 类型 | 描述 |
|---|---|---|---|
@complete | (instance, index) | Function | 当某个气泡的打字效果完成时触发的事件。 |
Ref 实例方法
| 属性名 | 类型 | 描述 |
|---|---|---|
scrollToTop | Function | 滚动到顶部。 |
scrollToBottom | Function | 滚动到底部。 |
scrollToBubble | Function | 滚动到指定气泡索引位置。 |
插槽
| 插槽名 | 参数 | 类型 | 描述 |
|---|---|---|---|
#avatar | - | Slot | 自定义头像展示内容 |
#header | - | Slot | 自定义气泡顶部展示内容 |
#content | - | Slot | 自定义气泡展示内容 |
#loading | - | Slot | 自定义气泡加载状态展示内容 |
#footer | - | Slot | 自定义气泡底部内容 |
功能特性
- 智能滚动 - 自动跟踪最新消息位置
- 深度定制 - 完整的气泡组件插槽透传
- 多种滚动方式 - 滚动到顶部、底部、指定位置
- 打字效果 - 支持打字效果
- 多种样式 - 支持多种样式,如圆形、方形等
