第 15 节:实现数据分析可视化

第 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:"查询女性员工数量"

本节小结

本节我们完成了数据分析可视化界面:

  1. 界面设计:创建了专门的 Workflow 查询界面
  2. 流式处理:实现了多步骤流式输出的接收和展示
  3. 步骤控制:处理了 step_start 等控制信号
  4. 内容累积:将所有步骤内容累积到一个字符串
  5. Markdown 渲染:展示格式化的查询过程和结果
  6. 用户体验:添加了加载状态和错误处理
  7. 交互优化:实现了自动滚动和实时更新

现在我们有了完整的数据分析功能。

思考与练习

思考题

  1. 为什么要将所有内容累积到一个字符串而不是分步骤展示?
  2. 如何优化大量数据的展示性能?
  3. 如果要支持图表可视化,应该如何设计?
  4. 如何实现查询结果的导出功能?

实践练习

  1. 功能扩展

    • 添加查询历史记录
    • 支持查询结果导出(CSV、Excel)
    • 添加查询收藏功能
  2. 可视化增强

    • 集成 ECharts 或 Chart.js
    • 根据数据类型自动选择图表
    • 支持图表交互和钻取
  3. 性能优化

    • 实现数据分页
    • 优化大数据集渲染
    • 添加虚拟滚动
  4. 用户体验

    • 添加查询模板
    • 支持查询参数化
    • 添加查询建议

上一节第 14 节:构建聊天交互界面
下一节第 16 节:前端状态管理与优化

相关推荐
星浩AI2 小时前
Claude Code 项目实战:多 Agent 流程编排,从原型到可运行 ChatBot
后端·claude·vibecoding
Lazy_zheng2 小时前
SDD 实战:用 Claude Code + OpenSpec,把 AI 编程变成“流水线”
前端·react.js·ai编程
何中应2 小时前
Claude Code本地部署
ai·ai编程·claude code
金木讲编程2 小时前
Claude Desktop 和 GitHub Copilot调用MCP Server 示例
github·copilot·ai编程
洛卡卡了3 小时前
Hermes Agent 火了,我也把它从安装到飞书聊天跑了一遍
人工智能·aigc·ai编程
探物 AI3 小时前
虾破苍穹(一):RTX 3060 养一只本地“呆呆”龙虾 [特殊字符]
人工智能·ai编程
财经资讯数据_灵砚智能3 小时前
基于全球经济类多源新闻的NLP情感分析与数据可视化(日间)2026年4月12日
大数据·人工智能·信息可视化·自然语言处理·ai编程
拖孩4 小时前
我用 AI 搓了一个"比谁更持久"的微信小游戏,AI实现只用了一天,微信审核却用了一个月!!!
微信小程序·ai编程·游戏开发
Ts-Drunk4 小时前
[特殊字符]深度解剖!Hermes-Agent 源码全解析(架构+核心流程+二次开发指南)
人工智能·架构·ai编程·hermes