第 15 节:实现数据分析可视化
阅读时间 :约 8 分钟
难度级别 :实战
前置知识:Vue 3、SSE 客户端
本节概要
通过本节学习,你将掌握:
- 设计 Workflow 查询界面的交互流程
- 处理 Workflow 的多步骤流式输出
- 实现查询结果的表格展示
- 优化数据可视化的用户体验
- 处理复杂的 SSE 数据格式
- 实现查询历史和状态管理
引言
数据分析可视化是 Text-to-BI 系统的核心功能。本节我们将学习如何构建 Workflow 查询界面,展示从查询分析到结果展示的完整过程。
数据分析界面是 Text-to-BI 系统的核心。本文将介绍如何构建 Workflow 查询界面,展示查询过程和结果。
🎯 本章目标
完成后,你将拥有:
- ✅ Workflow 查询界面
- ✅ 多步骤流程展示
- ✅ 表格数据可视化
- ✅ SQL 查询展示
- ✅ 数据分析展示
🎨 界面设计
与聊天界面的区别
聊天界面:
- 多轮对话
- 消息独立展示
- 简单的文本交互
Workflow 界面:
- 单次查询
- 步骤连续展示
- 复杂的数据展示
📝 创建 Workflow API
src/api/workflow.ts
typescript
import type { AxiosProgressEvent, GenericAbortSignal } from 'axios'
import { post } from './request'
/**
* Text-to-BI Workflow 查询(流式)
*/
export const streamWorkflow = (
params: { message: string; cubejs_url?: string },
onMessage: (content: string, isNewStep: boolean) => void,
abortSignal?: GenericAbortSignal
) => {
let previousLength = 0
return post({
url: '/workflow/query',
data: params,
signal: abortSignal,
responseType: 'text',
onDownloadProgress: (progressEvent: AxiosProgressEvent) => {
// 获取完整的响应数据
const rawData = progressEvent.event.target.response
if (!rawData || typeof rawData !== 'string') return
// 只处理新增的数据
const newData = rawData.slice(previousLength)
previousLength = rawData.length
if (!newData) return
// 解析 SSE 格式
const lines = newData.split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6).trim()
if (!data) continue
try {
const parsed = JSON.parse(data)
if (parsed === '[DONE]') {
return
}
// 检查是否是步骤控制信号
if (typeof parsed === 'object' && parsed.type) {
if (parsed.type === 'step_start') {
onMessage('', true) // 通知创建新步骤
} else if (parsed.type === 'step_end') {
// 步骤结束
}
} else if (typeof parsed === 'string') {
// 普通内容
onMessage(parsed, false)
}
} catch (e) {
// JSON 解析错误
}
}
}
},
})
}
🎭 创建 Workflow 组件
src/components/WorkflowPage.vue
模板部分:
vue
<template>
<div class="h-full flex flex-col" style="background: linear-gradient(135deg, #faf5ff 0%, #f3e8ff 100%);">
<!-- Header -->
<div class="flex-none border-b bg-white/80 backdrop-blur-sm shadow-sm">
<div class="flex items-center justify-between p-5">
<div>
<h1 class="text-xl font-bold bg-linear-to-r from-violet-600 to-purple-600 bg-clip-text text-transparent">
AI数据分析
</h1>
<p class="text-sm text-slate-600 mt-1">自然语言转SQL查询和执行</p>
</div>
<n-avatar
round
size="medium"
:style="{
background: 'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)',
boxShadow: '0 4px 12px rgba(139, 92, 246, 0.3)'
}"
>
<span style="font-weight: 600; font-size: 14px;">BI</span>
</n-avatar>
</div>
</div>
<!-- Content Area -->
<div class="flex-1 min-h-0 relative">
<n-scrollbar class="absolute inset-0" ref="scrollbarRef">
<div class="max-w-4xl mx-auto p-4">
<!-- Welcome Message -->
<div v-if="!userQuery" class="flex justify-center items-center min-h-[60vh]">
<n-empty description="输入自然语言问题,开始查询数据!" class="text-center">
<template #icon>
<n-icon size="48" style="color: #8b5cf6;">
<svg viewBox="0 0 24 24">
<path fill="currentColor" d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM9 17H7v-7h2v7zm4 0h-2V7h2v10zm4 0h-2v-4h2v4z"/>
</svg>
</n-icon>
</template>
</n-empty>
</div>
<!-- Workflow Steps -->
<div v-else class="space-y-6">
<!-- User Query -->
<div class="flex justify-end">
<div class="flex items-end space-x-2 max-w-[70%]">
<n-card
size="small"
class="shadow-md"
:style="{
background: 'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)',
color: 'white',
border: 'none'
}"
>
<div class="text-sm whitespace-pre-wrap">{{ userQuery }}</div>
</n-card>
<n-avatar
size="small"
class="shrink-0"
:style="{
background: 'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)',
boxShadow: '0 2px 8px rgba(139, 92, 246, 0.3)'
}"
>
<n-icon>
<svg viewBox="0 0 24 24">
<path fill="currentColor" d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
</svg>
</n-icon>
</n-avatar>
</div>
</div>
<!-- Workflow Output -->
<div class="flex items-start space-x-2">
<n-avatar
size="small"
class="shrink-0 mt-1"
:style="{
background: 'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)',
boxShadow: '0 2px 8px rgba(139, 92, 246, 0.3)',
fontWeight: '600',
fontSize: '12px'
}"
>
BI
</n-avatar>
<div class="flex-1">
<!-- Single Card for All Content -->
<n-card
size="small"
class="shadow-md"
:class="{ 'border-l-4 border-l-violet-500': isLoading }"
:style="{
backgroundColor: 'white',
border: '1px solid #e9d5ff'
}"
>
<div
class="text-sm text-slate-700 markdown-content"
v-html="renderMarkdown(allContent)"
></div>
<!-- Loading Indicator -->
<div v-if="isLoading" class="flex items-center mt-4 pt-4 border-t border-violet-200">
<n-spin size="small" class="mr-3" :style="{ color: '#8b5cf6' }" />
<span class="text-sm text-violet-600">正在执行 Workflow...</span>
</div>
</n-card>
</div>
</div>
</div>
<!-- 底部间距 -->
<div class="h-32"></div>
</div>
</n-scrollbar>
</div>
<!-- Input Area -->
<div class="flex-none border-t bg-white/80 backdrop-blur-sm shadow-lg">
<div class="p-5 pb-8">
<div class="max-w-4xl mx-auto">
<div class="flex space-x-3">
<n-input
v-model:value="inputMessage"
@keydown.enter="executeWorkflow"
:disabled="isLoading"
type="textarea"
placeholder="例如:统计员工总数、显示各部门员工数量..."
size="large"
class="flex-1"
:autosize="{ minRows: 1, maxRows: 4 }"
/>
<n-button
@click="executeWorkflow"
:disabled="isLoading || !inputMessage.trim()"
size="large"
:loading="isLoading"
class="px-6 shrink-0"
:style="{
background: 'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)',
border: 'none',
color: 'white',
fontWeight: '600',
boxShadow: '0 4px 12px rgba(139, 92, 246, 0.3)'
}"
>
<template #icon>
<n-icon v-if="!isLoading">
<svg viewBox="0 0 24 24">
<path fill="currentColor" d="M12 2L2 7v10c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V7l-10-5zm0 10l-4 4-1.41-1.41L9.17 12 6.59 9.41 8 8l4 4z"/>
</svg>
</n-icon>
</template>
<span class="hidden sm:inline">执行</span>
</n-button>
</div>
</div>
</div>
</div>
</div>
</template>
脚本部分:
vue
<script setup lang="ts">
import { ref, nextTick, onMounted } from 'vue'
import { useMessage } from 'naive-ui'
import { streamWorkflow } from '../api/workflow'
import { marked } from 'marked'
import hljs from 'highlight.js'
import 'highlight.js/styles/github-dark.css'
import '../styles/markdown.css'
// 配置 marked
marked.setOptions({
breaks: true,
gfm: true,
highlight: function(code: string, lang?: string) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(code, { language: lang }).value
} catch (err) {
// 高亮失败
}
}
return hljs.highlightAuto(code).value
}
} as any)
const allContent = ref('')
const userQuery = ref('')
const inputMessage = ref('')
const isLoading = ref(false)
const scrollbarRef = ref()
const message = useMessage()
let scrollTimer: number | null = null
// 渲染 Markdown 内容
const renderMarkdown = (content: string): string => {
if (!content) return ''
try {
const html = marked.parse(content) as string
return html
} catch (err) {
return content
}
}
const executeWorkflow = async () => {
if (!inputMessage.value.trim() || isLoading.value) return
const query = inputMessage.value.trim()
userQuery.value = query
inputMessage.value = ''
// 重置内容
allContent.value = ''
isLoading.value = true
await nextTick()
scrollToBottom()
try {
await streamWorkflow(
{ message: query },
(content: string, isNewStep: boolean) => {
if (isNewStep) {
// 新步骤开始,添加分隔
if (allContent.value) {
if (!allContent.value.endsWith('\n')) {
allContent.value += '\n'
}
allContent.value += '\n\n'
}
} else if (content) {
// 直接累积所有内容
allContent.value += content
}
// 防抖滚动
debouncedScrollToBottom()
}
)
} catch (error) {
allContent.value = '抱歉,执行Workflow时出现错误。请检查网络连接和后端服务是否正常运行。'
message.error('Workflow执行失败,请稍后重试')
} finally {
isLoading.value = false
await nextTick()
scrollToBottom()
}
}
const scrollToBottom = () => {
if (scrollbarRef.value) {
scrollbarRef.value.scrollTo({ position: 'bottom', behavior: 'smooth' })
}
}
const debouncedScrollToBottom = () => {
if (scrollTimer) {
clearTimeout(scrollTimer)
}
scrollTimer = setTimeout(() => {
scrollToBottom()
}, 50)
}
onMounted(() => {
nextTick(() => {
scrollToBottom()
})
})
</script>
🎨 关键功能实现
1. 步骤分隔
typescript
if (isNewStep) {
// 新步骤开始,添加分隔
if (allContent.value) {
if (!allContent.value.endsWith('\n')) {
allContent.value += '\n'
}
allContent.value += '\n\n'
}
}
2. 内容累积
typescript
else if (content) {
// 直接累积所有内容到一个字符串
allContent.value += content
}
3. 表格渲染
Markdown 表格会自动渲染:
markdown
| 部门 | 员工数量 |
| --- | --- |
| 技术部 | 150 |
| 销售部 | 120 |
渲染为:
| 部门 | 员工数量 |
|---|---|
| 技术部 | 150 |
| 销售部 | 120 |
4. SQL 代码高亮
typescript
marked.setOptions({
highlight: function(code: string, lang?: string) {
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang }).value
}
return hljs.highlightAuto(code).value
}
})
💡 Vibe Coding 要点
1. 复用聊天组件
diff
与 AI 对话:
"参考 ChatPage.vue,创建 WorkflowPage.vue:
- 使用相同的布局结构
- 修改颜色主题为紫色
- 单次查询而非多轮对话
- 所有内容在一个卡片中展示"
2. 渐进增强
第1版:复制聊天界面
第2版:修改为单次查询
第3版:添加步骤分隔
第4版:优化表格样式
第5版:添加加载状态
3. 测试不同查询
arduino
测试1:"统计员工总数"
测试2:"按性别统计员工数量"
测试3:"显示各部门员工分布"
测试4:"查询女性员工数量"
本节小结
本节我们完成了数据分析可视化界面:
- 界面设计:创建了专门的 Workflow 查询界面
- 流式处理:实现了多步骤流式输出的接收和展示
- 步骤控制:处理了 step_start 等控制信号
- 内容累积:将所有步骤内容累积到一个字符串
- Markdown 渲染:展示格式化的查询过程和结果
- 用户体验:添加了加载状态和错误处理
- 交互优化:实现了自动滚动和实时更新
现在我们有了完整的数据分析功能。
思考与练习
思考题
- 为什么要将所有内容累积到一个字符串而不是分步骤展示?
- 如何优化大量数据的展示性能?
- 如果要支持图表可视化,应该如何设计?
- 如何实现查询结果的导出功能?
实践练习
-
功能扩展:
- 添加查询历史记录
- 支持查询结果导出(CSV、Excel)
- 添加查询收藏功能
-
可视化增强:
- 集成 ECharts 或 Chart.js
- 根据数据类型自动选择图表
- 支持图表交互和钻取
-
性能优化:
- 实现数据分页
- 优化大数据集渲染
- 添加虚拟滚动
-
用户体验:
- 添加查询模板
- 支持查询参数化
- 添加查询建议
上一节 :第 14 节:构建聊天交互界面
下一节 :第 16 节:前端状态管理与优化