Appearance
BizChat 对话流
AI 客服 / 智能助手 / 工作台会话的气泡流:user 右侧主色、assistant 左侧白底、system 居中灰条;流式输出光标、打字三点指示、发送失败气泡内重试、滚顶加载历史、上翻暂停自动沉底(悬浮回底按钮)、内置输入区(Enter 发送 / Shift+Enter 换行)。messages 全量受控,业务侧维护数组即可驱动全部渲染。
基础用法(流式会话)
智能客服小智为您服务,请问有什么可以帮您?
小智 10:00
您好,请描述您遇到的问题,我会尽力解答。
发送后 mock「首包等待(typing)→ 逐字追加(status=pending 光标闪烁)→ done」,消息数组由父级维护,组件零接口。
<script setup lang="ts">
/** 基础用法:mock 会话 + 流式输出模拟(pending 光标 → 追加内容 → done) */
import { onBeforeUnmount, ref } from 'vue'
import { BizChat } from 'vue3-ep-biz-lib'
import type { BizChatMessage } from 'vue3-ep-biz-lib'
let seed = 2
const sending = ref(false)
const typing = ref(false)
const messages = ref<BizChatMessage[]>([
{ id: 1, role: 'system', content: '智能客服小智为您服务,请问有什么可以帮您?' },
{ id: 2, role: 'assistant', name: '小智', content: '您好,请描述您遇到的问题,我会尽力解答。', time: '10:00' },
])
const REPLIES = [
'已为您查询到订单 #202609290001,当前状态为「配送中」,预计今天 18:00 前送达,请保持电话畅通。',
'退款申请已提交,预计 1~3 个工作日原路退回;您可在「我的-退款售后」中查看进度。',
'抱歉给您带来不便,已为您加急转人工处理,请稍候。',
]
let timer: ReturnType<typeof setInterval> | undefined
const mockStream = (): void => {
const id = Date.now()
const reply = REPLIES[seed % REPLIES.length]
let cursor = 0
messages.value = [...messages.value, { id, role: 'assistant', name: '小智', status: 'pending', content: '' }]
timer = setInterval(() => {
cursor += 2
const done = cursor >= reply.length
messages.value = messages.value.map((m) =>
m.id === id ? { ...m, content: reply.slice(0, cursor), status: done ? 'done' : 'pending' } : m
)
if (done) {
if (timer !== undefined) clearInterval(timer)
sending.value = false
}
}, 60)
}
const onSend = (text: string): void => {
seed += 1
sending.value = true
messages.value = [...messages.value, { id: Date.now(), role: 'user', name: '我', content: text, time: '10:0' + seed }]
typing.value = true
setTimeout(() => {
typing.value = false
mockStream()
}, 600)
}
onBeforeUnmount(() => {
if (timer !== undefined) clearInterval(timer)
})
</script>
<template>
<BizChat
:messages="messages"
:sending="sending"
:typing="typing"
:height="380"
@send="onSend"
/>
</template>
显示代码
Markdown 注入 · 历史加载 · 失败重试
AI 助手
你好,我是 AI 助手,试试问我:写一段排序说明
我
冒泡排序的思路?
AI 助手
冒泡排序思路:
- 相邻元素两两比较,大的往后沉
- 每轮把最大值冒到末尾
const bubble = (arr) => arr
复杂度 O(n²),稳定排序。
render-html 注入 markdown-it 等渲染器(组件不内置 markdown 依赖,渲染安全由业务侧负责);滚顶触发 load-history;status=error 气泡内重试;footer 插槽挂复制等操作。<script setup lang="ts">
/** 进阶:markdown 渲染注入(render prop)+ 历史加载 + 失败重试 + 自定义 footer */
import MarkdownIt from 'markdown-it'
import { ElMessage } from 'element-plus'
import { ref } from 'vue'
import { BizChat, BizCopyText } from 'vue3-ep-biz-lib'
import type { BizChatMessage } from 'vue3-ep-biz-lib'
const md = new MarkdownIt({ breaks: true, linkify: true })
const render = (content: string): string => md.render(content)
const hasMore = ref(true)
const historyLoading = ref(false)
const sending = ref(false)
const seed = { n: 0 }
const messages = ref<BizChatMessage[]>([
{ id: 5, role: 'assistant', name: 'AI 助手', content: '你好,我是 **AI 助手**,试试问我:`写一段排序说明`' },
{ id: 6, role: 'user', name: '我', content: '冒泡排序的思路?' },
{
id: 7,
role: 'assistant',
name: 'AI 助手',
content:
'冒泡排序思路:\n\n1. 相邻元素两两比较,大的往后沉\n2. 每轮把**最大值**冒到末尾\n\n```js\nconst bubble = (arr) => arr\n```\n\n复杂度 *O(n²)*,稳定排序。',
},
])
const onLoadHistory = (): void => {
historyLoading.value = true
setTimeout(() => {
messages.value = [
{ id: 1, role: 'system', content: '以下为更早的历史消息' },
{ id: 2, role: 'assistant', name: 'AI 助手', content: '会话开始,欢迎提问。' },
...messages.value,
]
historyLoading.value = false
hasMore.value = false
}, 500)
}
const onSend = (text: string): void => {
seed.n += 1
const fail = seed.n % 2 === 0
sending.value = true
messages.value = [
...messages.value,
// 偶数条 mock 失败,演示气泡内重试
fail
? { id: Date.now(), role: 'user', name: '我', content: text, status: 'error' }
: { id: Date.now(), role: 'user', name: '我', content: text, status: 'done' },
]
setTimeout(() => {
sending.value = false
if (!fail) {
messages.value = [...messages.value, { id: Date.now() + 1, role: 'assistant', name: 'AI 助手', content: `收到:${text}` }]
}
}, 400)
}
const onRetry = (message: BizChatMessage): void => {
messages.value = messages.value.map((m) => (m.id === message.id ? { ...m, status: 'done' } : m))
ElMessage.success('已重试发送')
}
</script>
<template>
<BizChat
:messages="messages"
:render-html="render"
:has-more="hasMore"
:history-loading="historyLoading"
:sending="sending"
:height="420"
@send="onSend"
@retry="onRetry"
@load-history="onLoadHistory"
>
<template #footer="{ message }">
<div v-if="message.role === 'assistant'" style="margin-top: 6px">
<BizCopyText :text="message.content" show-tooltip>
<ElButton size="small" text type="primary">复制回答</ElButton>
</BizCopyText>
</div>
</template>
</BizChat>
</template>
显示代码
Markdown 流式回答
render-html prop 注入 markdown-it:标题/列表/代码块边生成边渲染(代码块渐出;prop 不可叫 render——Vue 保留名)。<script setup lang="ts">
/** markdown 渲染 + 流式输出:代码块渐出(render prop 正式 API) */
import MarkdownIt from 'markdown-it'
import { onBeforeUnmount, ref } from 'vue'
import { BizChat } from 'vue3-ep-biz-lib'
import type { BizChatMessage } from 'vue3-ep-biz-lib'
const md = new MarkdownIt({ breaks: true })
const render = (content: string): string => md.render(content)
const messages = ref<BizChatMessage[]>([])
const typing = ref(false)
let timer: ReturnType<typeof setInterval> | undefined
const REPLY = '排查思路:\n\n1. 查看 **网络面板** 是否有 4xx/5xx\n2. `npm run build` 本地复现\n\n```js\nconst retry = (fn, n = 3) => n <= 0 ? Promise.reject() : fn().catch(() => retry(fn, n - 1))\n```\n\n以上两步仍无法解决请附 **报错堆栈** 截图。'
const ask = (): void => {
messages.value = [...messages.value, { id: Date.now(), role: 'user', name: '我', content: '接口偶发 502 怎么排查?' }]
typing.value = true
setTimeout(() => {
typing.value = false
const id = Date.now() + 1
let cursor = 0
messages.value = [...messages.value, { id, role: 'assistant', name: 'AI 助手', status: 'pending', content: '' }]
timer = setInterval(() => {
cursor += 3
const done = cursor >= REPLY.length
messages.value = messages.value.map((m) => (m.id === id ? { ...m, content: REPLY.slice(0, cursor), status: done ? 'done' : 'pending' } : m))
if (done && timer !== undefined) clearInterval(timer)
}, 50)
}, 600)
}
onBeforeUnmount(() => {
if (timer !== undefined) clearInterval(timer)
})
</script>
<template>
<BizChat :messages="messages" :render-html="render" :typing="typing" :height="360">
<template #input>
<div style="display: flex; gap: 8px; margin-top: 12px">
<ElButton type="primary" @click="ask">问:接口偶发 502 怎么排查?</ElButton>
</div>
</template>
</BizChat>
</template>
显示代码
自绘输入区
客服小美
您好,请问需要什么帮助?(试试下方快捷短语)
怎么开发票?退款进度查询
input 插槽接管输入区:附件按钮 + 快捷短语(footer 槽推送候选问题)。
<script setup lang="ts">
/** input 插槽自绘输入区:附件/快捷短语 + 发送 */
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { BizChat } from 'vue3-ep-biz-lib'
import type { BizChatMessage } from 'vue3-ep-biz-lib'
const messages = ref<BizChatMessage[]>([
{ id: 1, role: 'assistant', name: '客服小美', content: '您好,请问需要什么帮助?(试试下方快捷短语)' },
])
const draft = ref('')
const sending = ref(false)
const send = (text?: string): void => {
const value = (text ?? draft.value).trim()
if (value === '') return
sending.value = true
messages.value = [...messages.value, { id: Date.now(), role: 'user', name: '我', content: value }]
draft.value = ''
setTimeout(() => {
messages.value = [...messages.value, { id: Date.now() + 1, role: 'assistant', name: '客服小美', content: `收到您的反馈:「${value}」,已为您记录工单。` }]
sending.value = false
}, 500)
}
const attach = (): void => {
ElMessage.info('自定义附件按钮(input 插槽完全接管输入区)')
}
</script>
<template>
<BizChat :messages="messages" :sending="sending" :height="320" :show-input="false">
<template #footer="{ message }">
<div v-if="message.role === 'assistant'" style="margin-top: 6px; display: flex; gap: 6px">
<ElTag size="small" style="cursor: pointer" @click="send('怎么开发票?')">怎么开发票?</ElTag>
<ElTag size="small" style="cursor: pointer" @click="send('退款进度查询')">退款进度查询</ElTag>
</div>
</template>
<template #input>
<div class="input-bar">
<ElButton text @click="attach">
<ElIcon><Paperclip /></ElIcon>
</ElButton>
<ElInput v-model="draft" placeholder="描述您的问题…" @keydown.enter="send()" />
<ElButton type="primary" :loading="sending" @click="send()">发送</ElButton>
</div>
</template>
</BizChat>
</template>
<style scoped>
.input-bar {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
}
</style>
显示代码
客服会话记录
会话开始 · 2026-09-29 10:21 · 渠道:App 内咨询
客户 138****5678 10:21
买的键盘三天了还没发货
客服小美 10:23
实在抱歉,您购买的蓝色款因入库延迟明天发出,给您补偿 10 元优惠券。
客户 138****5678 10:24
行吧,尽快
客户已结束会话 · 满意度 5 星
头像 / 时间 / 系统提示完整形态;footer 槽挂坐席快捷操作。
<script setup lang="ts">
/** 客服工作台会话:头像/时间/系统提示完整形态 */
import { ref } from 'vue'
import { BizChat } from 'vue3-ep-biz-lib'
import type { BizChatMessage } from 'vue3-ep-biz-lib'
const messages = ref<BizChatMessage[]>([
{ id: 1, role: 'system', content: '会话开始 · 2026-09-29 10:21 · 渠道:App 内咨询' },
{ id: 2, role: 'user', name: '客户 138****5678', avatar: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=64&h=64&fit=crop', content: '买的键盘三天了还没发货', time: '10:21' },
{ id: 3, role: 'assistant', name: '客服小美', avatar: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=64&h=64&fit=crop', content: '实在抱歉,您购买的蓝色款因入库延迟明天发出,给您补偿 10 元优惠券。', time: '10:23' },
{ id: 4, role: 'user', name: '客户 138****5678', avatar: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=64&h=64&fit=crop', content: '行吧,尽快', time: '10:24' },
{ id: 5, role: 'system', content: '客户已结束会话 · 满意度 5 星' },
])
</script>
<template>
<BizChat :messages="messages" :height="360" :show-input="false">
<template #footer="{ message }">
<div v-if="message.role === 'assistant'" style="margin-top: 6px; display: flex; gap: 8px">
<ElButton size="small" text type="primary">转人工</ElButton>
<ElButton size="small" text type="primary">推送优惠券</ElButton>
</div>
</template>
</BizChat>
</template>
显示代码
长回答与失败重试
AI 分析师
我可以为您生成周报摘要,试试下方按钮。长回答生成中可上翻阅读,新内容到达出现回底按钮。
长流式输出期间上翻阅读(暂停沉底 + 回底按钮);发送失败气泡内重试。
<script setup lang="ts">
/** 长流式回答 + 中断重试:失败重发演示(AI 生成报告场景) */
import { onBeforeUnmount, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { BizChat } from 'vue3-ep-biz-lib'
import type { BizChatMessage } from 'vue3-ep-biz-lib'
const messages = ref<BizChatMessage[]>([
{ id: 1, role: 'assistant', name: 'AI 分析师', content: '我可以为您生成周报摘要,试试下方按钮。长回答生成中可上翻阅读,新内容到达出现回底按钮。' },
])
const typing = ref(false)
const sending = ref(false)
let timer: ReturnType<typeof setInterval> | undefined
const REPLY = Array.from({ length: 14 }, (_, i) => `${i + 1}. 本周指标 ${100 + i * 7} 环比 ${i % 2 === 0 ? '+' : '-'}${(i * 1.7).toFixed(1)}%,核心驱动因素为渠道投放与老客召回策略的变化,建议持续观察后续两日数据拐点后再做预算调整。`).join('\n')
const generate = (fail = false): void => {
sending.value = true
messages.value = [...messages.value, { id: Date.now(), role: 'user', name: '我', content: '生成本周经营周报' }]
if (fail) {
setTimeout(() => {
messages.value = [...messages.value, { id: Date.now() + 1, role: 'user', name: '我', content: '生成本周经营周报', status: 'error' }]
sending.value = false
}, 400)
return
}
typing.value = true
setTimeout(() => {
typing.value = false
const id = Date.now() + 2
let cursor = 0
messages.value = [...messages.value, { id, role: 'assistant', name: 'AI 分析师', status: 'pending', content: '' }]
timer = setInterval(() => {
cursor += 24
const done = cursor >= REPLY.length
messages.value = messages.value.map((m) => (m.id === id ? { ...m, content: REPLY.slice(0, cursor), status: done ? 'done' : 'pending' } : m))
if (done) {
if (timer !== undefined) clearInterval(timer)
sending.value = false
}
}, 120)
}, 500)
}
const onRetry = (): void => {
ElMessage.info('重试请求')
generate(false)
}
onBeforeUnmount(() => {
if (timer !== undefined) clearInterval(timer)
})
</script>
<template>
<BizChat
:messages="messages"
:typing="typing"
:sending="sending"
:height="340"
@retry="onRetry"
>
<template #input>
<div style="display: flex; gap: 8px; margin-top: 12px">
<ElButton type="primary" :disabled="sending" @click="generate(false)">生成周报</ElButton>
<ElButton :disabled="sending" @click="generate(true)">模拟发送失败</ElButton>
</div>
</template>
</BizChat>
</template>
显示代码
输入区粘贴图片发送
技术支持 14:00
请把报错界面截图直接粘贴到输入框发送即可。
14:01
好的,我截个图
支持 Ctrl + V 粘贴图片
paste-image 开启后,输入区 Ctrl + V 粘贴的图片以 File[] 外抛 send-image(上传与消息追加由业务侧处理,组件零接口);非图片粘贴不受影响。<script setup lang="ts">
/** 输入区粘贴图片发送:pasteImage 开启后 Ctrl+V 直接发图(send-image 外抛 File[],上传与消息追加由业务侧处理) */
import { ref } from 'vue'
import { BizChat } from 'vue3-ep-biz-lib'
import type { BizChatMessage } from 'vue3-ep-biz-lib'
let seedId = 3
const messages = ref<BizChatMessage[]>([
{ id: 1, role: 'assistant', name: '技术支持', content: '请把报错界面截图直接粘贴到输入框发送即可。', time: '14:00' },
{ id: 2, role: 'user', content: '好的,我截个图', time: '14:01' },
])
/** 极简 markdown 图片渲染(真实项目建议 markdown-it;渲染结果安全由业务侧负责) */
const renderHtml = (content: string): string =>
content.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width:220px;border-radius:6px;display:block" />').replace(/\n/g, '<br/>')
/** 模拟上传:File -> 临时地址;真实项目替换为服务端上传接口 */
const upload = (file: File): Promise<string> =>
new Promise((resolve) => {
setTimeout(() => resolve(URL.createObjectURL(file)), 400)
})
const onSend = (text: string): void => {
messages.value.push({ id: seedId++, role: 'user', content: text, time: '14:02' })
}
const onSendImage = async (files: File[]): Promise<void> => {
for (const file of files) {
const url = await upload(file)
messages.value.push({ id: seedId++, role: 'user', content: ``, time: '14:02' })
}
messages.value.push({ id: seedId++, role: 'assistant', name: '技术支持', content: '已收到截图,正在排查…', time: '14:03' })
}
</script>
<template>
<BizChat
:messages="messages"
:height="380"
paste-image
:render-html="renderHtml"
@send="onSend"
@send-image="onSendImage"
/>
</template>
显示代码
Props
| 属性名 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| messages | BizChatMessage[] | [] | 消息列表(受控:追加 / 更新驱动渲染) |
| height | number | 480 | 滚动区高度(px) |
| has-more | boolean | false | 还有更早历史(滚顶触发 load-history) |
| history-loading | boolean | false | 历史加载中(顶部 loading 行) |
| typing | boolean | false | 打字三点指示(等首包期间) |
| sending | boolean | false | 发送中(输入区禁用 + 按钮 loading) |
| show-input | boolean | true | 显示内置输入区(false 时用 input 插槽自绘) |
| input-placeholder | string | '输入消息,Enter 发送…' | 输入占位文案 |
| auto-scroll | boolean | true | 新消息自动沉底(用户上翻阅读时自动暂停) |
| render-html | (content: string) => string | — | 富文本渲染器注入(markdown-it 等),缺省纯文本 pre-wrap |
| paste-image | boolean | false | 内置输入区支持 Ctrl+V 粘贴图片:剪贴板图片以 File[] 外抛 send-image(上传与消息追加由业务侧处理);非图片粘贴走默认文本行为 |
BizChatMessage:id、role(user / assistant / system)、content 必填;name / avatar / time 展示元信息;status(pending 流式光标 / error 失败重试 / done)。
Events
| 事件名 | 参数 | 说明 |
|---|---|---|
| send | (text: string) | 内置输入区发送(Enter / 按钮) |
| send-image | (files: File[]) | paste-image 开启后输入区粘贴的图片文件(上传与消息追加由业务侧处理) |
| retry | (message: BizChatMessage) | 点击失败气泡的重试 |
| load-history | — | 滚动到顶且 has-more 时触发 |
Slots
| 插槽名 | 说明 |
|---|---|
| message | 气泡内容整体自定义(作用域:message、render 渲染函数) |
| footer | 气泡下方操作区(作用域:message,如复制 / 重新生成) |
| input | 输入区整体自定义(覆盖内置) |
Expose
| 属性/方法 | 说明 |
|---|---|
| scrollToBottom | () => void 手动沉底 |