markdown数据
类似于ai回复效果 内容一字一字缓慢加载效果
结合marked插件分析类似markdowm数据
数据结果如下:
npm i marked --save
Json文件
{
"code": 1,
"message": "",
"success": true,
"data": "### 数据分析\n\n1. **通话时长分布概览**:\n - **0-5分钟**:9次\n - **5-10分钟**:6次\n - **10-15分钟**:6次\n - **15-20分钟**:8次\n - **20-25分钟**:5次\n - **25-30分钟**:1次\n - **30-35分钟**:4次\n - **40-45分钟**:1次\n - **45-50分钟**:1次\n\n2. **主要发现**:\n - **短时长通话(0-5分钟)**:占比最高,达到9次,占总通话量的约25.7%。这表明大部分通话是简短的,可能是快速查询或简单问题解决。\n - **中等时长通话(5-20分钟)**:占比也较高,合计20次,占总通话量的约57.1%。这些通话可能是较为复杂的问题解决或详细咨询。\n - **长时长通话(20分钟以上)**:占比相对较低,合计12次,占总通话量的约34.3%。这些通话可能是复杂问题或需要深入讨论的情况。\n\n3. **异常值**:\n - **40-45分钟**和**45-50分钟**:各1次,可能是特殊情况或异常通话。\n\n### 建议\n\n1. **优化短时长通话服务**:\n - **自助服务**:增加自助服务选项,如IVR(交互式语音应答)系统,帮助客户快速解决简单问题,减少人工通话量。\n - **知识库**:建立完善的知识库,让客户可以通过网站或APP自助查询常见问题。\n\n2. **提升中等时长通话效率**:\n - **培训**:加强客服人员的培训,提高问题解决效率,减少通话时长。\n - **工具支持**:提供更好的工具支持,如CRM系统,帮助客服快速获取客户信息和历史记录,提高服务效率。\n\n3. **处理长时长通话**:\n - **问题分类**:对长时长通话进行详细分类,找出常见问题,优化解决方案。\n - **专家支持**:对于复杂问题,提供专家支持或转接服务,确保问题得到有效解决。\n\n4. **监控异常通话**:\n - **异常检测**:建立异常通话检测机制,及时发现和处理异常通话,防止资源浪费。\n - **回访机制**:对异常通话进行回访,了解具体情况,优化服务流程。\n\n### 总结\n\n通过对通话时长分布的分析,可以看出大部分通话集中在短时长和中等时长区间。建议通过优化自助服务、加强培训和工具支持,提升服务效率,减少通话时长。同时,关注和处理长时长通话和异常通话,确保服务质量。"
}
写法1
<template>
<div>
<!-- 用于打字效果和最终显示带格式的 Markdown 内容的容器 -->
<div
ref="typingContainer"
class="typing-container"
:class="{ 'result-streaming': isStreaming }"
></div>
</div>
</template>
<script lang='ts'>
import { defineComponent, ref, onMounted, watch, nextTick } from 'vue';
import { marked } from 'marked';
import model from '../db.json';
export default defineComponent({
name: 'MarkdownDisplay',
setup() {
const typingContainer = ref<HTMLElement | null>(null);
const markdownContent = ref(model.data || '');
const isStreaming = ref(false);
// 渲染Markdown到HTML
const renderMarkdown = () => {
return marked(markdownContent.value, { sanitize: true });
};
// 打字效果实现
const typeText = async (container: HTMLElement, node: Node, delay: number = 50) => {
if (node.nodeType === Node.TEXT_NODE) {
const text = (node as Text).data;
for (let char of text) {
await new Promise(resolve => setTimeout(resolve, delay));
container.appendChild(document.createTextNode(char));
}
} else if (node.nodeType === Node.ELEMENT_NODE) {
const element = node.cloneNode(false) as HTMLElement;
container.appendChild(element);
// 处理子节点
for (const childNode of Array.from((node as HTMLElement).childNodes)) {
await typeText(element, childNode, delay);
}
}
};
// 更新内容并触发打字效果
const updateMarkdownContent = async () => {
if (!typingContainer.value) return;
isStreaming.value = true;
typingContainer.value.innerHTML = '';
const tempDiv = document.createElement('div');
tempDiv.innerHTML = renderMarkdown();
try {
for (const node of Array.from(tempDiv.childNodes)) {
await typeText(typingContainer.value, node);
}
} finally {
isStreaming.value = false;
}
};
// 生命周期和监听
onMounted(async () => {
await nextTick();
await updateMarkdownContent();
});
watch(markdownContent, async () => {
await updateMarkdownContent();
});
return {
typingContainer,
isStreaming
};
},
});
</script>
<style scoped>
/* 容器基础样式 */
.typing-container {
width: 800px;
height: 500px;
overflow-y: auto;
font-family: 'Consolas', monospace;
line-height: 1.6;
padding: 20px;
border: 1px solid #e0e0e0;
border-radius: 8px;
background: #f8f9fa;
}
/* 光标闪烁动画 */
@keyframes blink-caret {
from, to { opacity: 0; }
50% { opacity: 1; }
}
/* 流式输出时的光标效果 */
.typing-container.result-streaming::after {
content: "▋";
display: inline-block;
margin-left: 2px;
animation: blink-caret 1s step-end infinite;
vertical-align: baseline;
}
/* 优化Markdown元素间距 */
:deep(.typing-container) p {
margin: 0.8em 0;
}
:deep(.typing-container) h1,
:deep(.typing-container) h2,
:deep(.typing-container) h3 {
margin: 1.2em 0 0.8em;
}
:deep(.typing-container) ul,
:deep(.typing-container) ol {
padding-left: 1.5em;
margin: 0.8em 0;
}
:deep(.typing-container) li {
margin: 0.4em 0;
}
/* 代码块样式 */
:deep(.typing-container) pre {
background: #f3f4f6;
padding: 1em;
border-radius: 4px;
overflow-x: auto;
margin: 1em 0;
}
:deep(.typing-container) code {
font-family: 'Consolas', monospace;
font-size: 0.9em;
}
</style>
写法2
<template>
<div class="sse-container">
<div ref="contentBox" class="markdown-content">
<div v-if="loading" class="loading">加载中...</div>
<div v-if="error" class="error">加载失败: {{ error }}</div>
<div v-html="processedContent" :class="{ streaming }"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed,nextTick} from 'vue'
import { marked } from 'marked'
// 配置Markdown解析
marked.setOptions({
breaks: true, // 将\n转换为<br>
gfm: true // 支持GitHub风格Markdown
})
// 响应式数据
const contentBox = ref<HTMLElement>()
const rawContent = ref('')
const loading = ref(true)
const error = ref('')
const streaming = ref(true)
// 处理后的Markdown内容
const processedContent = computed(() => {
return marked(rawContent.value)
})
// 完整模拟数据(保留原有数据)
const mockData = { data: `### 数据分析\n\n1. **通话时长分布概览**:\n - **0-5分钟**:9次\n - **5-10分钟**:6次\n - **10-15分钟**:6次\n - **15-20分钟**:8次\n - **20-25分钟**:5次\n - **25-30分钟**:1次\n - **30-35分钟**:4次\n - **40-45分钟**:1次\n - **45-50分钟**:1次\n\n2. **主要发现**:\n - **短时长通话(0-5分钟)**:占比最高,达到9次,占总通话量的约25.7%。这表明大部分通话是简短的,可能是快速查询或简单问题解决。\n - **中等时长通话(5-20分钟)**:占比也较高,合计20次,占总通话量的约57.1%。这些通话可能是较为复杂的问题解决或详细咨询。\n - **长时长通话(20分钟以上)**:占比相对较低,合计12次,占总通话量的约34.3%。这些通话可能是复杂问题或需要深入讨论的情况。\n\n3. **异常值**:\n - **40-45分钟**和**45-50分钟**:各1次,可能是特殊情况或异常通话。\n\n### 建议\n\n1. **优化短时长通话服务**:\n - **自助服务**:增加自助服务选项,如IVR(交互式语音应答)系统,帮助客户快速解决简单问题,减少人工通话量。\n - **知识库**:建立完善的知识库,让客户可以通过网站或APP自助查询常见问题。\n\n2. **提升中等时长通话效率**:\n - **培训**:加强客服人员的培训,提高问题解决效率,减少通话时长。\n - **工具支持**:提供更好的工具支持,如CRM系统,帮助客服快速获取客户信息和历史记录,提高服务效率。\n\n3. **处理长时长通话**:\n - **问题分类**:对长时长通话进行详细分类,找出常见问题,优化解决方案。\n - **专家支持**:对于复杂问题,提供专家支持或转接服务,确保问题得到有效解决。\n\n4. **监控异常通话**:\n - **异常检测**:建立异常通话检测机制,及时发现和处理异常通话,防止资源浪费。\n - **回访机制**:对异常通话进行回访,了解具体情况,优化服务流程。\n\n### 总结\n\n通过对通话时长分布的分析,可以看出大部分通话集中在短时长和中等时长区间。建议通过优化自助服务、加强培训和工具支持,提升服务效率,减少通话时长。同时,关注和处理长时长通话和异常通话,确保服务质量。` } // 保持原数据不变
// 拆分数据为字符块(优化换行处理)
const chunkData = (data: string) => {
// 按行拆分后再按字符拆分
return data.split('\n').flatMap(line => {
const chunks = []
for (let i = 0; i < line.length; i += 5) {
chunks.push(line.slice(i, i + 5))
}
chunks.push('\n') // 保留换行符
return chunks
})
}
onMounted(async () => {
const chunks = chunkData(mockData.data)
let currentChunk = 0
// 优化后的流式处理
const simulateStream = async () => {
try {
loading.value = false
streaming.value = true
while (currentChunk < chunks.length) {
await new Promise(resolve => setTimeout(resolve, 50))
// 添加内容并保留换行符
rawContent.value += chunks[currentChunk]
currentChunk++
// 立即触发解析
forceRender()
autoScroll()
}
streaming.value = false
} catch (err) {
error.value = (err as Error).message
loading.value = false
}
}
simulateStream()
})
// 强制渲染处理
let renderTimer: number
const forceRender = () => {
clearTimeout(renderTimer)
renderTimer = setTimeout(() => {
// 通过重新赋值触发计算属性更新
const temp = rawContent.value
rawContent.value = ''
rawContent.value = temp
}, 50)
}
// 优化自动滚动
const autoScroll = () => {
nextTick(() => {
if (contentBox.value) {
const isScrolledToBottom =
contentBox.value.scrollHeight - contentBox.value.clientHeight <=
contentBox.value.scrollTop + 50
if (isScrolledToBottom) {
contentBox.value.scrollTop = contentBox.value.scrollHeight
}
}
})
}
// 清理
onUnmounted(() => {
clearTimeout(renderTimer)
})
</script>
<style scoped>
/* 新增样式优化 */
.markdown-content ::v-deep(p) {
margin: 0.8em 0;
line-height: 1.7;
}
.markdown-content ::v-deep(br) {
content: "";
display: block;
margin-bottom: 0.5em;
}
.markdown-content ::v-deep(ul) {
margin-left: 1.2em;
}
.markdown-content ::v-deep(li) {
margin: 0.5em 0;
position: relative;
}
/* 优化光标动画 */
.streaming::after {
animation: blink 1s step-end infinite, pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }
}
</style>
EventSource实现SSE流式输出
数据格式为如下
[
'# 实时 Markdown 流式演示\n\n',
'**加粗文本** 和 _斜体文本_\n\n',
'```javascript\nconsole.log("代码块");\n```\n\n',
'列表示例:\n',
'- 第一项\n',
'- 第二项\n',
'- 第三项\n\n',
'[链接示例](https://example.com)',
]
<template>
<div class="stream-container">
<div ref="streamContent" class="markdown-content" :class="{ streaming }"></div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { marked } from 'marked'
// 模拟 SSE 数据流
const mockStreamData = [
'# 实时 Markdown 流式演示\n\n',
'**加粗文本** 和 _斜体文本_\n\n',
'```javascript\nconsole.log("代码块");\n```\n\n',
'列表示例:\n',
'- 第一项\n',
'- 第二项\n',
'- 第三项\n\n',
'[链接示例](https://example.com)',
]
// 组件逻辑
const streamContent = ref<HTMLElement>()
const streaming = ref(true)
let currentIndex = 0
// 创建模拟的 EventSource
const createMockStream = () => {
return {
onmessage: (callback: (event: MessageEvent) => void) => {
const sendChunk = () => {
if (currentIndex < mockStreamData.length) {
const event = new MessageEvent('message', {
data: mockStreamData[currentIndex]
})
callback(event)
currentIndex++
setTimeout(sendChunk, 500)//可以改变输出的速度
} else {
streaming.value = false
}
}
sendChunk()
},
close: () => {
currentIndex = mockStreamData.length
}
}
}
// 渲染流式内容
const renderStream = async (chunk: string) => {
if (!streamContent.value) return
const cursor = document.createElement('span')
cursor.className = 'typing-cursor'
// 保留当前滚动位置
const shouldScroll =
streamContent.value.scrollTop + streamContent.value.clientHeight >=
streamContent.value.scrollHeight - 50
// 创建临时容器
const tempDiv = document.createElement('div')
tempDiv.innerHTML = await marked.parse(chunk)
// 逐字输出
for (const node of tempDiv.childNodes) {
await typeEffect(node as HTMLElement)
}
// 保持滚动位置
if (shouldScroll) {
streamContent.value.scrollTop = streamContent.value.scrollHeight
}
}
// 逐字输出效果
const typeEffect = async (element: HTMLElement) => {
if (!streamContent.value) return
const walker = document.createTreeWalker(
element,
NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT
)
while (walker.nextNode()) {
const node = walker.currentNode
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent || ''
let displayText = ''
for (const char of text) {
displayText += char
node.textContent = displayText
await new Promise(resolve => setTimeout(resolve, 20))
}
} else if (node instanceof HTMLElement) {
const clone = node.cloneNode(true) as HTMLElement
streamContent.value.appendChild(clone)
await typeEffect(clone)
}
}
}
// 组件生命周期
onMounted(() => {
const eventSource = createMockStream()
eventSource.onmessage((event) => renderStream(event.data))
onUnmounted(() => {
eventSource.close()
})
})
</script>
<style scoped>
.stream-container {
max-width: 800px;
margin: 2rem auto;
padding: 1rem;
background: #f8fafc;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.markdown-content {
min-height: 400px;
max-height: 600px;
overflow-y: auto;
padding: 1rem;
background: white;
border-radius: 4px;
line-height: 1.6;
font-family: 'Segoe UI', system-ui;
}
.markdown-content.streaming::after {
content: "▋";
animation: blink 1s step-end infinite;
color: #3b82f6;
margin-left: 2px;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* Markdown 样式增强 */
:deep(h1) {
color: #1e293b;
border-bottom: 2px solid #e2e8f0;
padding-bottom: 0.3em;
}
:deep(strong) {
color: #3b82f6;
font-weight: 600;
}
:deep(em) {
color: #10b981;
font-style: italic;
}
:deep(pre) {
background: #1e293b;
color: #f8fafc;
padding: 1rem;
border-radius: 6px;
overflow-x: auto;
margin: 1rem 0;
}
:deep(code) {
font-family: 'Fira Code', monospace;
font-size: 0.9em;
}
:deep(ul) {
padding-left: 1.5em;
list-style-type: '→ ';
}
:deep(a) {
color: #3b82f6;
text-decoration: underline;
transition: opacity 0.2s;
}
:deep(a:hover) {
opacity: 0.8;
}
</style>