Spiga

Net企业级AI项目5:生成式UI

2026-02-07 18:07:32

一、前端环境搭建

1. 技术选型

前端 UI 我们采用 VUE + TS + Vite + Element Plus + ECharts + Pinia + SPA 来实现。

  • 步骤 1,创建 Vue 项目,在 src 目录下执行:
npm create vue@latest

项目名称: Qjy.AICopilot.VueUI

功能:TypeScript、Pinia、ESLint、Prettier

  • 步骤 2,安装项目依赖包,我们进入到刚刚创建的 vue 项目,分别执行下面命令
cd Qjy.AICopilot.VueUI
npm install
npm install element-plus @element-plus/icons-vue
npm install echarts
npm install markdown-it @microsoft/fetch-event-source
npm install -D @types/markdown-it
  • 步骤 3,启用 pinia 和 element-plus
//Qjy.AICoplot.VueUI/src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'

import App from './App.vue'

const app = createApp(App)  // 创建应用实例

app.use(createPinia())  // 注册状态管理插件
app.use(ElementPlus)    // 注册 ElementPlus 插件

app.mount('#app') // 挂载到 index.html 的 #app 节点

2. Aspire 集成

  • 步骤 4,使用 Aspire 启动 vue 项目。先在 Asprise 项目安装 nuget 包:CommunityToolkit.Aspire.Hosting.NodeJS.Extensions
//Qjy.AICopilot.AppHost/AppHost.cs
builder.AddViteApp("aicopilot-vueui", "../Qjy.AICopilot.VueUI")
    .WithEndpoint("http", endpointAnnotation =>
    {
        endpointAnnotation.Port = 5173;
    })
    .WaitFor(httpapi)
    .WithReference(httpapi);
  • 步骤 5,前端反向代理调用后台 httpapi
//Qjy.AICoplot.VueUI/vite.config.ts
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import { env } from 'node:process';
import { baseUrl } from './src/appsetting'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'

// https://vite.dev/config/
export default defineConfig(({ mode }) => {
  // 动态获取 Aspire 注入的后端地址
  // 格式通常为: {服务名}_http
  // 这里的 'aicopilot_httpapi' 必须与 AppHost 中 AddProject 的名称一致
  const target = env.aicopilot_httpapi_http;

  console.log('Detected Backend Target:', target); // 调试用,启动时打印地址

  return {
    plugins: [
      vue(),
      vueDevTools(),
    ],
    resolve: {
      alias: {
        '@': fileURLToPath(new URL('./src', import.meta.url))
      },
    },
    server: {
      host: true,
      proxy: {
        // 配置反向代理
        // 所有以 baseUrl 开头的请求,都会被转发到 target
        [baseUrl]: {
          target: target,
          changeOrigin: true,
          secure: false, // 开发环境通常使用自签名证书,需要关闭安全验证
        }
      }
    }
  }
})

asspetting.ts 是一个后端信息的配置文件,我们这里为了方便把 token 写死了。

//Qjy.AICoplot.VueUI/src/appsetting.ts
export const token = "xxx";
export const baseUrl = '/api';

完成上面步骤后就可以使用 Asprise 同时启动前后端项目了,并且会通过 Asprise 将后台的地址注入到前端应用的环境变量中,前端通过 env.aicopilot_httpapi_http 来访问,这种访问方式没有跨域问题。

二、前端架构设计

1. 前端分层思想

前端我们也使用分层设计的思想,我们把前端分成:

  • 数据模型层:负责数据格式的定义
  • API 服务层:负责跟后台 API 交互
  • 状态管理层:通过 Pinia 管理状态数据
  • 视图层:采用组件设计思想,分块完成各个组件的设计

2. 数据模型定义

  • 后端数据模型(protocols.ts):一比一复刻后台结构,方便直接解析。
//Qjy.AICoplot.VueUI/src/types/protocols.ts
// ---------------------- 数据传输对象 ----------------------
/**
 * 对应后端的 Session 实体
 * 简化的会话信息
 */
export interface Session {
  id: string;
  title: string;
}

/**
 * 消息发送者枚举
 */
export enum MessageRole {
  User = 'User',
  Assistant = 'Assistant'
}

/**
 * 对应后端的 ChunkType 枚举
 * 决定了消息流中的数据块是纯文本还是可视化组件
 */
export enum ChunkType {
  Error = 'Error',
  Text = 'Text',
  Intent = 'Intent',
  Widget = 'Widget',
  FunctionResult = 'FunctionResult',
  FunctionCall = 'FunctionCall'
}

/**
 * 对应后端的 ChatChunk 类
 * 这是流式响应中每一次传输的最小单元
 */
export interface ChatChunk {
  source: string;     // 执行器ID,用于追踪是谁生成的
  type: ChunkType;    // 数据类型
  content: string;    // 内容载体(文本或JSON字符串)
}

/**
 * 对应后端的 IntentResult 实体
 * 意图结果
 */
export interface IntentResult {
  intent: string;
  confidence: number;
  reasoning?: string;
  query?: string;
}

// ---------------------- 可视化组件相关定义 ----------------------
/**
 * 基础组件接口
 */
export interface Widget {
  id: string;      // 组件唯一标识
  type: string;    // 组件类型:'Chart', 'StatsCard', 'DataTable'
  title: string;  // 组件标题
  description: string; // 描述
  data: any;       // 具体的数据载体,根据类型不同而不同
}

/**
 * 对应后端的 ChartWidget
 */
export interface ChartWidget extends Widget {
  type: 'Chart';
  data: {
    category: 'Bar' | 'Line' | 'Pie';
    dataset: {
      dimensions: string[];
      source: Array<Record<string, any>>;
    };
    encoding: {
      x: string;
      y: string[];
      seriesName?: string;
    };
  };
}

/**
 * 对应后端的 StatsCardWidget
 */
export interface StatsCardWidget extends Widget {
  type: 'StatsCard';
  data: {
    label: string;
    value: string | number;
    unit?: string;
  };
}

/**
 * 对应后端的 DataTableWidget
 */
export interface DataTableWidget extends Widget {
  type: 'DataTable';
  data: {
    columns: Array<{
      key: string;
      label: string;
      dataType: 'string' | 'number' | 'date' | 'boolean';
    }>;
    rows: Array<Record<string, any>>;
  };
}
  • 前端数据模型(models.ts):负责定义一些后台返回过来的数据无法满足前端显示的要求,或者一些前端专用的数据模型。
    • FunctionCall 接口,将函数调用与函数返回合并成一个数据模型
    • 又函数调用的返回值是一个 json ,其格式是不确定,所以我们还进一步扩展 ChatChunk 数据模型
    • 然后定义了一个前端专用的消息模型 ChatMessage,后端发过来的数据是碎片化的,我们需要在前端进行整合后显示
//Qjy.AICoplot.VueUI/src/types/models.ts
import { type ChatChunk, type IntentResult, MessageRole, type Widget } from "@/types/protocols.ts";

// ---------------------- 前端数据结构 ----------------------
/**
 * 函数调用信息结构
 * FunctionCallContent + FunctionResultContent 合并
 */
export interface FunctionCall {
  id: string;
  name: string;
  args: string;
  result?: string;
  status: 'calling' | 'completed';
}

/**
 * 扩展消息块-意图识别块
 */
export interface IntentChunk extends ChatChunk {
  intents: IntentResult[]
}

/**
 * 扩展消息块-函数调用块
 */
export interface FunctionCallChunk extends ChatChunk {
  functionCall: FunctionCall;
}

/**
 * 扩展消息块-组件块
 */
export interface WidgetChunk extends ChatChunk {
  widget: Widget;
}

/**
 * 前端使用的消息模型
 */
export interface ChatMessage {
  sessionId: string;
  role: MessageRole;
  chunks: ChatChunk[];
  isStreaming: boolean;
  timestamp: number;
}

3. API 服务层

  • API 代理(apiClient.ts):主要是封装 fetch,提供简化的 get 和 post 方法。
//Qjy.AICoplot.VueUI/src/services/apiClient.ts
import { token, baseUrl } from "@/appsetting";

/**
 * 基础 API 客户端
 * 封装了 fetch,统一处理 URL 前缀和 JSON 序列化
 */
export const apiClient = {
  /**
   * 获取统一的请求头
   */
  getHeaders(): HeadersInit {
    return {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${token}` // 注入认证头
    };
  },

  /**
   * 发送 POST 请求
   */
  async post<T>(endpoint: string, body: any): Promise<T> {
    const response = await fetch(`${baseUrl}${endpoint}`, {
      method: 'POST',
      headers: this.getHeaders(),
      body: JSON.stringify(body)
    });

    if (!response.ok) {
      // 如果 401,提示 Token 可能过期
      if (response.status === 401) {
        console.error("Token 无效或已过期");
      }
      throw new Error(`API Error: ${response.statusText}`);
    }

    // 尝试解析 JSON
    try {
      return await response.json();
    } catch {
      return {} as T; // 处理空响应
    }
  },

  /**
   * 发送 GET 请求
   */
  async get<T>(endpoint: string): Promise<T> {
    const response = await fetch(`${baseUrl}${endpoint}`, {
      method: 'GET',
      headers: this.getHeaders()
    });

    if (!response.ok) {
      if (response.status === 401) {
        console.error("Token 无效或已过期,请更新 TEST_TOKEN");
      }
      throw new Error(`API Error: ${response.statusText}`);
    }

    return await response.json();
  }
};
  • 流水处理(chatService.ts):流式通信服务,默认不支持 post,使用微软的库来实现 post 的 SSE。关键点 StreamCallbacks,自定义的回调处理事件
//Qjy.AICoplot.VueUI/src/services/chatService.ts
import { fetchEventSource } from '@microsoft/fetch-event-source';
import { apiClient } from './apiClient';
import { token, baseUrl } from "@/appsetting";
import type { ChatChunk } from "@/types/protocols.ts";

/**
 * 定义流式回调函数的接口
 * 上层调用者(Store)通过这些回调接收数据
 */
interface StreamCallbacks {
  onChunkReceived: (chunk: ChatChunk) => void;       // 当收到数据块时
  onComplete: () => void;                            // 当流结束时
  onError: (err: any) => void;                       // 当发生错误时
}

export const chatService = {
  /**
   * 获取会话列表
   */
  async getSessions() {
    return await apiClient.get<any[]>('/aigateway/session/list');
  },

  /**
   * 创建新会话
   */
  async createSession() {
    return await apiClient.post<any>('/aigateway/session', {});
  },

  /**
   * 发送消息并接收流式响应
   * @param sessionId 会话ID
   * @param message 用户输入的内容
   * @param callbacks 回调函数集合
   */
  async sendMessageStream(sessionId: string, message: string, callbacks: StreamCallbacks) {
    const ctrl = new AbortController();

    try {
      // 使用微软的库发起 SSE 请求
      await fetchEventSource(`${baseUrl}/aigateway/chat`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${token}`
        },
        body: JSON.stringify({
          sessionId: sessionId,
          message: message
        }),
        signal: ctrl.signal,

        // 1. 处理连接打开
        async onopen(response) {
          if (response.ok) {
            return; // 连接成功
          } else {
            throw new Error(`连接失败: ${response.status}`);
          }
        },

        // 2. 处理消息接收
        onmessage(msg) {
          try {
            // 解析后端发来的 ChatChunk JSON
            const chunk: ChatChunk = JSON.parse(msg.data);
            console.log(chunk);
            callbacks.onChunkReceived(chunk);
          } catch (err) {
            console.error('无法解析区消息块:', err);
          }
        },

        // 3. 处理连接关闭
        onclose() {
          callbacks.onComplete();
        },

        // 保持连接,即使页面进入后台
        openWhenHidden: true
      });
    } catch (err) {
      callbacks.onError(err);
    }
  }
};

4. 状态管理

这是前端响应式的内存数据库,封装前端数据展示的核心逻辑。

//Qjy.AICoplot.VueUI/src/stores/chatStore.ts
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import { chatService } from '@/services/chatService.ts';
import {
  type ChatChunk,
  ChunkType,
  type IntentResult,
  MessageRole,
  type Session, type Widget
} from "@/types/protocols";
import type {
  ChatMessage,
  FunctionCall,
  FunctionCallChunk, IntentChunk,
  WidgetChunk
} from "@/types/models.ts";

export const useChatStore = defineStore('chat', () => {
  // ================= 状态 (State) =================
  // 会话列表
  const sessions = ref<Session[]>([]);
  // 当前选中的会话 ID
  const currentSessionId = ref<string | null>(null);
  // 消息记录字典:Key是会话ID,Value是该会话的消息列表
  const messagesMap = ref<Record<string, ChatMessage[]>>({});
  // 正在接收消息的标志
  const isStreaming = ref(false);

  // ================= 计算属性 =================
  /**
   * 获取当前会话的所有消息
   */
  const currentMessages = computed(() => {
    if (!currentSessionId.value) return [];
    return messagesMap.value[currentSessionId.value] || [];
  });
  /**
   * 获取当前选中的会话对象
   */
  const currentSession = computed(() => {
    if (!currentSessionId.value) return { title: '当前没有选择会话' } as Session;
    return sessions.value
      .find(session => session.id === currentSessionId.value)
  });

  // ================= 动作 (Actions) =================
  /**
   * 初始化:加载会话列表
   */
  async function init() {
    try {
      sessions.value = await chatService.getSessions();
    } catch (error) {
      console.error('无法加载会话', error);
    }
  }

  /**
   * 创建新会话并选中
   */
  async function createNewSession() {
    const newSession = await chatService.createSession();
    sessions.value.unshift(newSession);
    currentSessionId.value = newSession.id;
    messagesMap.value[newSession.id] = [];
  }

  /**
   * 切换会话
   */
  async function selectSession(id: string) {
    currentSessionId.value = id;
  }

  /**
   * 发送消息的核心逻辑
   */
  async function sendMessage(input: string) {
    if (!currentSessionId.value) {
      alert('请先选择一个会话');
      return;
    }
    if (isStreaming.value) {
      alert('请等待上一条消息的回复完成');
      return;
    }

    const sessionId = currentSessionId.value;

    // 1. 在 UI 上立即显示用户的消息
    const userMsg: ChatMessage = {
      sessionId,
      role: MessageRole.User,
      chunks: [{
        source: 'User',
        type: ChunkType.Text,
        content: input
      }],
      isStreaming: false,
      timestamp: Date.now()
    };
    addMessage(sessionId, userMsg);

    // 2. 预先创建一个空的 AI 回复消息(占位符)
    const aiMsg: ChatMessage = {
      sessionId,
      role: MessageRole.Assistant,
      chunks: [], // 初始为空,随流动态增加
      isStreaming: true,
      timestamp: Date.now()
    };
    const targetMsg = addMessage(sessionId, aiMsg);

    isStreaming.value = true;

    // 3. 调用 API 服务,开始接收流
    await chatService.sendMessageStream(sessionId, input, {
      onChunkReceived: (chunk: ChatChunk) => {
        switch (chunk.type) {
          case ChunkType.Text:
            addTextChunk(targetMsg, chunk);
            break;
          case ChunkType.Intent:
            addIntentChunk(targetMsg, chunk);
            break;
          case ChunkType.FunctionCall:
            addFunctionCallChunk(targetMsg, chunk);
            break;
          case ChunkType.FunctionResult:
            addFunctionResultChunk(targetMsg, chunk);
            break;
          case ChunkType.Widget:
            addWidgetChunk(targetMsg, chunk);
        }
      },

      // 完成时
      onComplete: () => {
        isStreaming.value = false;
        targetMsg.isStreaming = false;
      },

      // 错误时
      onError: (err) => {
        isStreaming.value = false;
      }
    });
  }

  // ================= 辅助函数 (Internal) =================
  /**
   * 发送消息的核心逻辑
   */
  function addMessage(sid: string, msg: ChatMessage): ChatMessage {
    if (!messagesMap.value[sid]) {
      messagesMap.value[sid] = [];
    }
    const list = messagesMap.value[sid];
    list.push(msg);
    return list[list.length - 1]!;
  }

  /**
   * 添加文本块
   */
  function addTextChunk(msg: ChatMessage, chunk: ChatChunk) {
    const preChunk = msg.chunks[msg.chunks.length - 1];

    if (preChunk === undefined) {
      msg.chunks.push(chunk);
      return;
    }

    if (preChunk.source === chunk.source && preChunk.type === ChunkType.Text) {
      preChunk.content += chunk.content;
    } else {
      msg.chunks.push(chunk);
    }
  }

  /**
   * 添加意图识别块
   */
  function addIntentChunk(msg: ChatMessage, chunk: ChatChunk) {
    const intents = JSON.parse(chunk.content) as IntentResult[];
    const intentChunk = {
      ...chunk,
      intents
    } as IntentChunk;
    msg.chunks.push(intentChunk);
  }

  /**
   * 添加函数调用块
   */
  function addFunctionCallChunk(msg: ChatMessage, chunk: ChatChunk) {
    const functionCall = JSON.parse(chunk.content) as FunctionCall;
    functionCall.status = 'calling';

    const fcChunk = {
      ...chunk,
      functionCall
    } as FunctionCallChunk;
    msg.chunks.push(fcChunk);
  }

  /**
   * 添加函数结果块
   */
  function addFunctionResultChunk(msg: ChatMessage, chunk: ChatChunk) {
    const functionResult = JSON.parse(chunk.content) as FunctionCall;
    const functionCallChunks = msg.chunks
      .filter(c => c.type === ChunkType.FunctionCall) as FunctionCallChunk[];
    const fcChunk = functionCallChunks.find(c => c.functionCall.id === functionResult.id);
    if (fcChunk) {
      fcChunk.functionCall.result = functionResult.result;
      fcChunk.functionCall.status = 'completed';
    }
  }

  /**
   * 添加组件块
   */
  function addWidgetChunk(msg: ChatMessage, chunk: ChatChunk) {
    const widget = JSON.parse(chunk.content) as Widget;
    const widgetChunk = {
      ...chunk,
      widget
    } as WidgetChunk
    msg.chunks.push(widgetChunk);
  }

  // 导出
  return {
    sessions,
    currentSessionId,
    currentSession,
    currentMessages,
    isStreaming,
    init,
    createNewSession,
    selectSession,
    sendMessage
  };
});

三、实现视图层

1. 实现基础聊天界面

  • 启动页面,启用 chatStore,创建一个 ChatWindow 的组件
//Qjy.AICoplot.VueUI/src/App.vue
<script setup lang="ts">
  import { onMounted } from 'vue';
  import { useChatStore } from './stores/chatStore';
  import ChatWindow from './components/chat/ChatWindow.vue';

  const chatStore = useChatStore();

  // 应用启动时,自动获取历史会话
  onMounted(async () => {
    await chatStore.init();
    // 如果没有会话,自动创建一个新的,避免空白
    if (chatStore.sessions.length === 0) {
      await chatStore.createNewSession();
    }
  });
</script>

<template>
  <div class="app-container">
    <ChatWindow />
  </div>
</template>

<style scoped>
  .app-container {
    height: 100vh;
    display: flex;
    justify-content: center;
    align-items: center;
    font-family: 'Inter', sans-serif;
  }
</style>
  • 全局样式
//Qjy.AICoplot.VueUI/src/assets/main.css
:root {
  /* 定义语义化颜色变量 */
  --bg-color-primary: #ffffff;
  --bg-color-secondary: #f3f5f7; /* 侧边栏背景 */
  --border-color: #e5e7eb;
  --text-primary: #1f2937;
  --text-secondary: #6b7280;
  /* 品牌色 - 对应 Element Plus 的 Primary */
  --brand-color: #409eff;
  /* 消息气泡颜色 */
  --bubble-bg-user: #e1effe;
  --bubble-bg-ai: #f3f4f6;
}

body {
  margin: 0;
  padding: 0;
  font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  background-color: var(--bg-color-primary);
  color: var(--text-primary);
  height: 100vh;
  overflow: hidden; /* 防止整个页面出现滚动条,只让内部组件滚动 */
}

#app {
  height: 100%;
  width: 100%;
}

/* 滚动条美化 */
::-webkit-scrollbar {
  width: 6px;
  height: 6px;
}

::-webkit-scrollbar-thumb {
  background: #d1d5db;
  border-radius: 3px;
}

::-webkit-scrollbar-track {
  background: transparent;
}
  • 聊天页面布局:我们采用左右分布,左边是一个会话列表;右边是消息信息和发送消息操作。
//Qjy.AICoplot.VueUI/src/components/chat/ChatWindow.vue
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue';
import { useChatStore } from '@/stores/chatStore.ts';
import SessionList from './SessionList.vue';
import MessageItem from './MessageItem.vue';
import { Promotion } from '@element-plus/icons-vue';

const store = useChatStore();
const inputValue = ref('');
const scrollContainer = ref<HTMLElement | null>(null);

// 发送消息处理
const handleSend = async () => {
  const content = inputValue.value.trim();
  if (!content || store.isStreaming) return;

  // 清空输入框
  inputValue.value = '';

  // 调用 Store 发送
  await store.sendMessage(content);
};

// 滚动到底部逻辑
const scrollToBottom = async () => {
  await nextTick(); // 等待 Vue DOM 更新完成
  if (scrollContainer.value) {
    scrollContainer.value.scrollTop = scrollContainer.value.scrollHeight;
  }
};

// 监听消息列表变化,自动滚动
// deep: true 确保能监听到数组内部元素的变化(如流式传输时的内容追加)
watch(
  () => store.currentMessages,
  () => {
    scrollToBottom();
  },
  { deep: true }
);

// 切换会话时也要滚动到底部
watch(
  () => store.currentSessionId,
  () => {
    scrollToBottom();
  }
);
</script>

<template>
  <div class="chat-layout">
    <div class="sidebar-wrapper">
      <SessionList />
    </div>

    <div class="main-wrapper">
      <header class="chat-header">
        <h2>前金院智能助手</h2>
      </header>

      <main class="chat-viewport" ref="scrollContainer">
        <div class="messages-list">
          <div v-if="store.currentMessages.length === 0" class="welcome-banner">
            <h3>👋 欢迎使用前金院 AI Copilot</h3>
          </div>
          <MessageItem
            v-for="msg in store.currentMessages"
            :key="msg.timestamp"
            :message="msg"
          />
        </div>
      </main>

      <footer class="chat-input-area">
        <div class="input-container">
          <el-input v-model="inputValue"
                    type="textarea"
                    :autosize="{ minRows: 1, maxRows: 4 }"
                    placeholder="输入您的问题 (Enter 发送, Shift+Enter 换行)..."
                    @keydown.enter.prevent="(e:KeyboardEvent) => { if(!e.shiftKey) handleSend() }"
                    :disabled="store.isStreaming" />
          <el-button type="primary"
                     class="send-btn"
                     :disabled="!inputValue.trim() || store.isStreaming"
                     @click="handleSend">
            <el-icon><Promotion /></el-icon>
          </el-button>
        </div>
        <div class="suggestion-chips">
          <el-tag @click="inputValue='分析各仓库库存占比';handleSend()" class="chip">分析各仓库库存占比</el-tag>
          <el-tag @click="inputValue='查询最近一周的销售趋势'" class="chip">查询销售趋势</el-tag>
        </div>
        <div class="footer-tip">
          AI 生成的内容可能不准确,请核实重要信息。
        </div>
      </footer>
    </div>
  </div>
</template>

<style scoped>
.chat-layout {
  display: flex;
  height: 100%;
  width: 100%;
  overflow: hidden;
}

.sidebar-wrapper {
  width: 260px;
  flex-shrink: 0;
}

.main-wrapper {
  flex: 1;
  display: flex;
  flex-direction: column;
  background-color: var(--bg-color-primary);
  position: relative;
}

.chat-header {
  height: 60px;
  border-bottom: 1px solid var(--border-color);
  display: flex;
  align-items: center;
  padding: 0 24px;
  font-weight: 600;
}

.chat-viewport {
  flex: 1;
  overflow-y: auto;
  padding: 24px;
  scroll-behavior: smooth; /* 平滑滚动 */
}

.messages-list {
  max-width: 800px; /* 限制内容最大宽度,提升阅读体验 */
  margin: 0 auto;
}

.chat-input-area {
  padding: 24px;
  border-top: 1px solid var(--border-color);
  background-color: #fff;
}

.input-container {
  max-width: 800px;
  margin: 0 auto;
  display: flex;
  gap: 12px;
  align-items: flex-end; /* 底部对齐 */
}

.send-btn {
  height: 40px;
  width: 40px;
  border-radius: 8px;
}

.footer-tip {
  text-align: center;
  color: var(--text-secondary);
  font-size: 12px;
  margin-top: 8px;
}

.welcome-banner {
  text-align: center;
  margin-top: 100px;
  color: var(--text-secondary);
}

.suggestion-chips {
  margin-top: 20px;
  display: flex;
  justify-content: center;
  gap: 10px;
}

.chip {
  cursor: pointer;
}
</style>
  • 会话列表组件:
//Qjy.AICoplot.VueUI/src/components/chat/SessionList.vue
<script setup lang="ts">
import { computed } from 'vue';
import { useChatStore } from '@/stores/chatStore.ts';
import { Plus, ChatDotRound } from '@element-plus/icons-vue';

// 连接 Store
const store = useChatStore();

// 计算属性:会话列表
const sessions = computed(() => store.sessions);
const currentId = computed(() => store.currentSessionId);

// 处理点击
const handleSelect = (id: string) => {
  store.selectSession(id);
};

const handleNewChat = () => {
  store.createNewSession();
};
</script>

<template>
  <div class="session-sidebar">
    <div class="sidebar-header">
      <el-button
        type="primary"
        class="new-chat-btn"
        :icon="Plus"
        @click="handleNewChat"
      >
        新建会话
      </el-button>
    </div>

    <div class="session-list">
      <div
        v-for="session in sessions"
        :key="session.id"
        class="session-item"
        :class="{ active: currentId === session.id }"
        @click="handleSelect(session.id)"
      >
        <el-icon class="icon"><ChatDotRound /></el-icon>
        <span class="title">{{ session.title }}</span>
      </div>

      <div v-if="sessions.length === 0" class="empty-tip">
        暂无历史会话
      </div>
    </div>
  </div>
</template>

<style scoped>
.session-sidebar {
  display: flex;
  flex-direction: column;
  height: 100%;
  background-color: var(--bg-color-secondary);
  border-right: 1px solid var(--border-color);
}

.sidebar-header {
  padding: 20px;
  flex-shrink: 0; /* 防止头部被压缩 */
}

.new-chat-btn {
  width: 100%;
  border-radius: 8px;
}

.session-list {
  flex: 1; /* 占据剩余高度 */
  overflow-y: auto; /* 内部滚动 */
  padding: 0 12px;
}

.session-item {
  display: flex;
  align-items: center;
  padding: 12px 16px;
  margin-bottom: 8px;
  border-radius: 8px;
  cursor: pointer;
  color: var(--text-primary);
  transition: all 0.2s ease;
  font-size: 14px;
}

.session-item:hover {
  background-color: rgba(0, 0, 0, 0.05);
}

.session-item.active {
  background-color: #e6f0ff; /* 激活态背景色 */
  color: var(--brand-color);
  font-weight: 500;
}

.session-item .icon {
  margin-right: 10px;
  font-size: 16px;
}

.session-item .title {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis; /* 文字过长显示省略号 */
}

.empty-tip {
  text-align: center;
  color: var(--text-secondary);
  font-size: 12px;
  margin-top: 20px;
}
</style>
  • 消息体组件
//Qjy.AICoplot.VueUI/src/components/chat/MessageItem.vue
<script setup lang="ts">
import { computed } from 'vue';
import { UserFilled, Service } from '@element-plus/icons-vue';
import { MessageRole } from '@/types/protocols.ts';
import BlockIntent from './BlockIntent.vue';
import BlockAnalysis from './BlockAnalysis.vue';
import BlockFinal from './BlockFinal.vue';
import type {ChatMessage, IntentChunk} from "@/types/models.ts";

const props = defineProps<{
  message: ChatMessage
}>();

const isUser = computed(() => props.message.role === MessageRole.User);

const intents = computed(() =>{
  const chunk = props.message.chunks.find(chunk => chunk.source === 'IntentRoutingExecutor') as IntentChunk;
  return chunk?.intents || [];
});

const dataChunks = computed(() =>
  props.message.chunks.filter(chunk => chunk.source === 'DataAnalysisExecutor') || []
);

const finalChunks = computed(() =>
  props.message.chunks.filter(chunk => chunk.source === 'FinalProcessExecutor' || chunk.source === 'User') || []
);
</script>

<template>
  <div class="message-row" :class="{ 'row-reverse': isUser }">
    <div class="avatar-container">
      <el-avatar
        :size="36"
        :icon="isUser ? UserFilled : Service"
        :class="isUser ? 'avatar-user' : 'avatar-ai'"
        :style="{ backgroundColor: isUser ? '#f0f0f0' : '#e1f3d8' }"
      />
    </div>

    <div class="content-container">
      <BlockIntent
        v-if="intents.length > 0"
        :intents="intents"/>

      <BlockAnalysis
        v-if="dataChunks.length > 0"
        :chunks="dataChunks"
        :is-streaming="message.isStreaming"/>

      <BlockFinal
        v-if="finalChunks.length > 0"
        :chunks="finalChunks"
        :is-user="isUser"
        :is-streaming="message.isStreaming"
      />
    </div>
  </div>
</template>

<style scoped>
.message-row { display: flex; margin-bottom: 20px; align-items: flex-start; }
.row-reverse { flex-direction: row-reverse; }
.avatar-container { margin: 0 10px; }
.content-container { max-width: 85%; display: flex; flex-direction: column; gap: 10px; }
.avatar-user { color: #666; }
.avatar-ai { background-color: #409eff; color: white; }
</style>

目前我们已经完成了页面布局,上面代码中有一些组件还没有实现,下面会继续完成

2. 可视化组件渲染

后端返回的内容包括3部分,意图识别、函数调用和数据分析。前端需要对这3种返回的内容动态进行渲染,在 MessageItem.vue 中已经布局好了这3个组件,接下来我们继续完善它们

  • Markdown 解析器,由于 AI 返回过来的内容是 markdown 格式的,我们先封装一个 markdown 内容转 html 内容的工具方法。
//Qjy.AICoplot.VueUI/src/utils/markdown.ts
import MarkdownIt from 'markdown-it';

// 初始化实例
const md = new MarkdownIt({
  html: false,        // 禁用 HTML 标签
  linkify: true,      // 自动识别 URL 为链接
  breaks: true,       // 转换换行符为 <br>
  typographer: true   // 启用一些语言学的替换和引号美化
});

/**
 * 将 Markdown 文本渲染为 HTML 字符串
 */
export function renderMarkdown(text: string): string {
  if (!text) return '';
  return md.render(text);
}
  • 意图识别组件
//Qjy.AICoplot.VueUI/src/components/chat/BlockIntent.vue
<script setup lang="ts">
import { Opportunity } from '@element-plus/icons-vue';
import type {IntentResult} from "@/types/protocols.ts";

defineProps<{
  intents: IntentResult[]
}>();

const getIntentColor = (confidence: number) => {
  if (confidence > 0.8) return 'success';
  if (confidence > 0.5) return 'warning';
  return 'danger';
};
</script>

<template>
  <div class="block-intent">
    <el-collapse :model-value="[]">
      <el-collapse-item name="intent">
        <template #title>
          <div class="intent-header">
            <el-icon><Opportunity /></el-icon>
            <span class="label">意图识别</span>
            <div class="intent-tags" v-if="intents.length > 0">
              <template v-for="(item) in intents">
                <el-tag
                  size="small"
                  :type="getIntentColor(item.confidence)"
                  effect="light"
                  class="ml-2 intent-tag"
                >
                  {{ item.intent }} {{ (item.confidence * 100).toFixed(0) }}%
                </el-tag>
              </template>
            </div>
          </div>
        </template>

        <div class="intent-body" v-if="intents.length > 0">
          <div v-for="(item, idx) in intents" :key="idx" class="intent-item">
            <div class="info-row">
              <span class="label">意图:</span>
              <span class="value"><strong>{{ item.intent }}</strong></span>
            </div>
            <div class="info-row" v-if="item.query">
              <span class="label">关键词:</span>
              <span class="value">{{ item.query }}</span>
            </div>
            <div class="info-row" v-if="item.reasoning">
              <span class="label">推理:</span>
              <span class="value">{{ item.reasoning }}</span>
            </div>
            <el-divider v-if="idx < intents.length - 1" class="intent-divider"/>
          </div>
        </div>
      </el-collapse-item>
    </el-collapse>
  </div>
</template>

<style scoped>
.block-intent { background: #fff; border-radius: 8px; border: 1px solid #ebeef5; overflow: hidden; }
.intent-header { display: flex; align-items: center; padding-left: 10px; color: #606266; width: 100%; }
.intent-tags { display: flex; flex-wrap: wrap; gap: 6px; flex: 1; margin-left: 10px; }
.label { font-size: 13px; font-weight: 500; margin-right: 8px; white-space: nowrap; }
.intent-body { padding: 10px 15px; background-color: #f9fafe; font-size: 13px; color: #555; }
.intent-divider { margin: 8px 0; border-top: 1px dashed #dcdfe6; }
.info-row { margin-bottom: 4px; display: flex; }
.info-row .label { color: #888; width: 60px; flex-shrink: 0; }

@keyframes rotating { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }

/* 覆盖 Element Plus */
:deep(.el-collapse-item__header) { height: auto; min-height: 40px; border-bottom: 1px solid #ebeef5; }
:deep(.el-collapse-item__content) { padding-bottom: 0; }
:deep(.el-collapse) { border: none; }
</style>
  • 函数调用组件,函数调用又分成调用和返回2个步骤
//Qjy.AICoplot.VueUI/src/components/chat/BlockFinal.vue
<script setup lang="ts">
import {renderMarkdown} from '@/utils/markdown';
import FunctionCallItem from './FunctionCallItem.vue';
import {type ChatChunk, ChunkType} from "@/types/protocols.ts";
import type {FunctionCallChunk} from "@/types/models.ts";

const props = defineProps<{
  chunks: ChatChunk[]
  isUser: boolean;
  isStreaming: boolean;
}>();

const getFunctionCall = (chunk : ChatChunk): FunctionCallChunk =>
  chunk as FunctionCallChunk;

</script>

<template>
  <div class="block-final message-bubble" :class="isUser ? 'bubble-user' : 'bubble-ai'">
    <template v-for="chunk in chunks">
      <div
        v-if="chunk.type === ChunkType.Text"
        class="markdown-body inline-block-container"
        v-html="renderMarkdown(chunk.content)"
      ></div>

      <div
        v-else-if="chunk.type === ChunkType.FunctionCall"
        class="my-1 inline-block"
      >
        <FunctionCallItem
          :call="getFunctionCall(chunk).functionCall"
          :mini="true"
        />
      </div>

      <span v-if="isStreaming" class="cursor-blink">|</span>
    </template>
  </div>
</template>

<style scoped>
.message-bubble {
  padding: 10px 14px;
  border-radius: 8px;
  font-size: 14px;
  line-height: 1.6;
  position: relative;
  word-break: break-word;
}

/* 用户气泡 */
.bubble-user {
  background-color: #95ec69;
  color: #000;
}

/* AI 气泡 */
.bubble-ai {
  background-color: #fff;
  border: 1px solid #e4e7ed;
  color: #333;
}

.cursor-blink {
  display: inline-block;
  width: 2px;
  height: 14px;
  background: #333;
  animation: blink 1s infinite;
  vertical-align: middle;
  margin-left: 2px;
}

.inline-block { display: inline-block; }
.inline-block-container { display: inline-block; width: 100%; }
.my-1 { margin: 4px 0; }

/* 修正 markdown 里的 p 标签 margin,使其在 steps 拼接时更自然 */
:deep(.markdown-body p:last-child) { margin-bottom: 0; }
:deep(.markdown-body p:first-child) { margin-top: 0; }

@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
</style>
//Qjy.AICoplot.VueUI/src/components/chat/FunctionCallItem.vue
<script setup lang="ts">
import { computed } from 'vue';
import {
  CircleCheck,
  CircleClose,
  Loading,
  Operation // 通用图标
} from '@element-plus/icons-vue';
import type {FunctionCall} from "@/types/models.ts";

const props = defineProps<{
  call: FunctionCall;
  mini?: boolean; // 迷你模式(用于最终回复块)
}>();

// --- 状态计算 ---
const isRunning = computed(() => props.call.status === 'calling');
const isSuccess = computed(() => props.call.status === 'completed');

// --- 样式配置 ---
const statusColor = computed(() => {
  if (isRunning.value) return '#E6A23C'; // 橙色:执行中
  if (isSuccess.value) return '#67C23A'; // 绿色:成功
  return '#909399';
});

const statusIcon = computed(() => {
  if (isRunning.value) return Loading;
  if (isSuccess.value) return CircleCheck;
  return CircleClose;
});

const statusText = computed(() => {
  if (isRunning.value) return '正在执行...';
  if (isSuccess.value) return '调用成功';
  return '调用失败';
});

// 格式化参数显示 (尝试格式化 JSON,失败则显示原文本)
const formattedArgs = computed(() => {
  try {
    const json = JSON.parse(props.call.args);
    return JSON.stringify(json, null, 2);
  } catch (e) {
    return props.call.args;
  }
});
</script>

<template>
  <div
    v-if="mini"
    class="fc-mini"
    :class="{ 'is-running': isRunning, 'is-success': isSuccess }"
  >
    <div class="fc-mini-header">
      <el-icon class="icon-indicator" :class="{ 'is-loading': isRunning }">
        <component :is="statusIcon" />
      </el-icon>
      <span class="fc-name font-mono">{{ call.name }}()</span>
    </div>
  </div>

  <div
    v-else
    class="fc-item"
    :class="{
      'border-running': isRunning,
      'border-success': isSuccess
    }"
  >
    <el-collapse>
      <el-collapse-item>
        <template #title>
          <div class="fc-header">
            <div
              class="status-icon-box"
              :style="{
                borderColor: statusColor,
                backgroundColor: isRunning ? '#fff' : statusColor,
                color: isRunning ? statusColor : '#fff'
              }"
            >
              <el-icon :class="{ 'is-loading': isRunning }">
                <component :is="statusIcon" />
              </el-icon>
            </div>

            <div class="fc-info">
              <div class="fc-title-row">
                <span class="fc-label">Function Call:</span>
                <span class="fc-name">{{ call.name }}</span>
              </div>
              <div class="fc-status-text" :style="{ color: statusColor }">
                {{ statusText }}
              </div>
            </div>

            <div v-if="isRunning" class="running-indicator">
              <span class="dot"></span>
              <span class="dot"></span>
              <span class="dot"></span>
            </div>
          </div>
        </template>

        <div class="fc-details">
          <div class="detail-section">
            <div class="section-label">Parameters (Input):</div>
            <pre class="code-block json-content">{{ formattedArgs }}</pre>
          </div>

          <div class="detail-section mt-2">
            <div class="section-label">Execution Result:</div>

            <div v-if="isRunning" class="result-loading">
              <el-icon class="is-loading"><Loading /></el-icon>
              <span>Waiting for output...</span>
            </div>

            <pre v-else class="code-block result-content">{{ call.result || 'No return value' }}</pre>
          </div>
        </div>
      </el-collapse-item>
    </el-collapse>
  </div>
</template>

<style scoped>
/* === 通用 === */
.font-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }

/* === 完整模式样式 === */
.fc-item {
  margin-bottom: 8px;
  background: #fff;
  border: 1px solid #e4e7ed;
  border-radius: 6px;
  transition: all 0.3s ease; /* 平滑过渡 */
  overflow: hidden;
}

/* 状态边框 & 呼吸动画 */
.border-running {
  border-color: #f3d19e;
  box-shadow: 0 0 8px rgba(230, 162, 60, 0.2);
  animation: pulse-border 2s infinite;
}
.border-success {
  border-color: #e1f3d8;
  border-left: 4px solid #67C23A; /* 左侧加粗强调 */
}
.border-failed {
  border-color: #fde2e2;
  border-left: 4px solid #F56C6C;
}

/* Header 布局 */
.fc-header {
  display: flex;
  align-items: center;
  width: 100%;
  padding-left: 10px;
  height: 100%;
}

.status-icon-box {
  width: 24px;
  height: 24px;
  border-radius: 50%;
  border: 1px solid;
  display: flex;
  align-items: center;
  justify-content: center;
  margin-right: 12px;
  font-size: 14px;
  transition: all 0.3s;
}

.fc-info {
  display: flex;
  flex-direction: column;
  line-height: 1.3;
}
.fc-title-row {
  display: flex;
  align-items: center;
  font-size: 13px;
  color: #303133;
}
.fc-label {
  color: #909399;
  margin-right: 6px;
  font-size: 12px;
}
.fc-name {
  font-weight: 600;
  font-family: monospace;
  color: #409eff;
}
.fc-status-text {
  font-size: 11px;
}

/* 详情区 */
.fc-details {
  padding: 12px;
  background: #fafafa;
  border-top: 1px solid #ebeef5;
}
.detail-section { margin-bottom: 8px; }
.section-label {
  font-size: 11px;
  text-transform: uppercase;
  color: #909399;
  margin-bottom: 4px;
  font-weight: 600;
}
.code-block {
  margin: 0;
  font-family: monospace;
  font-size: 12px;
  white-space: pre-wrap;
  word-break: break-all;
  padding: 8px;
  border-radius: 4px;
  border: 1px solid #e4e7ed;
}
.json-content { background-color: #fff; color: #606266; }
.result-content { background-color: #f0f9eb; color: #529b2e; border-color: #e1f3d8; }

/* Result Loading 占位符 */
.result-loading {
  display: flex;
  align-items: center;
  gap: 8px;
  color: #909399;
  font-size: 12px;
  padding: 8px;
  background: #fff;
  border: 1px dashed #dcdfe6;
  border-radius: 4px;
}

/* === 迷你模式样式 === */
.fc-mini {
  display: inline-flex;
  align-items: center;
  font-size: 12px;
  padding: 2px 8px;
  border-radius: 12px;
  border: 1px solid #e9e9eb;
  background: #f4f4f5;
  color: #909399;
  margin-right: 8px;
}
.fc-mini-header { display: flex; align-items: center; gap: 6px; }

.fc-mini.is-running {
  background: #fdf6ec;
  border-color: #faecd8;
  color: #e6a23c;
}
.fc-mini.is-success {
  background: #f0f9eb;
  border-color: #e1f3d8;
  color: #67c23a;
}
.fc-mini.is-failed {
  background: #fef0f0;
  border-color: #fde2e2;
  color: #f56c6c;
}

/* === 动画 === */
@keyframes pulse-border {
  0% { box-shadow: 0 0 0 0 rgba(230, 162, 60, 0.4); }
  70% { box-shadow: 0 0 0 6px rgba(230, 162, 60, 0); }
  100% { box-shadow: 0 0 0 0 rgba(230, 162, 60, 0); }
}

/* Element Plus Override */
:deep(.el-collapse) { border: none; }
:deep(.el-collapse-item__header) {
  height: auto;
  min-height: 48px;
  padding: 6px 0;
  border-bottom: none;
  background: transparent;
}
:deep(.el-collapse-item__wrap) { border-bottom: none; }
:deep(.el-collapse-item__content) { padding-bottom: 0; }

/* 运行中的三个点动画 */
.running-indicator {
  margin-left: auto;
  margin-right: 10px;
  display: flex;
  gap: 3px;
}
.dot {
  width: 4px;
  height: 4px;
  background-color: #e6a23c;
  border-radius: 50%;
  animation: bounce 1.4s infinite ease-in-out both;
}
.dot:nth-child(1) { animation-delay: -0.32s; }
.dot:nth-child(2) { animation-delay: -0.16s; }
@keyframes bounce { 0%, 80%, 100% { transform: scale(0); } 40% { transform: scale(1); } }
</style>
  • 数据分析组件
//Qjy.AICoplot.VueUI/src/components/chat/BlockAnalysis.vue
<script setup lang="ts">
import {DataLine, Monitor} from '@element-plus/icons-vue';
import {renderMarkdown} from '@/utils/markdown';
import FunctionCallItem from './FunctionCallItem.vue';
import {type ChatChunk, ChunkType} from "@/types/protocols.ts";
import type {FunctionCallChunk, WidgetChunk} from "@/types/models.ts";
import WidgetRenderer from '../widgets/WidgetRenderer.vue';

defineProps<{
  chunks: ChatChunk[]
  isStreaming: boolean
}>();

const getFunctionCall = (chunk: ChatChunk): FunctionCallChunk =>
  chunk as FunctionCallChunk;

const getWidget = (chunk: ChatChunk): WidgetChunk =>
  chunk as WidgetChunk;

</script>

<template>
  <div class="block-analysis">
    <div class="analysis-card">
      <div class="analysis-header">
        <el-icon class="header-icon"><DataLine /></el-icon>
        <span>数据分析与决策</span>
        <span v-if="isStreaming" class="typing-dot">...</span>
      </div>
      <div class="analysis-content">
        <template v-for="chunk in chunks">
          <div
            v-if="chunk.type === ChunkType.Text"
            class="markdown-body text-analysis mb-3"
            v-html="renderMarkdown(chunk.content)"
          ></div>

          <div v-else-if="chunk.type === ChunkType.FunctionCall" class="mb-3">
            <FunctionCallItem
            :call="getFunctionCall(chunk).functionCall"
            />
          </div>

          <div v-else-if="chunk.type === ChunkType.Widget" class="mb-3 widget-wrapper">
            <WidgetRenderer :data="getWidget(chunk).widget" />
          </div>
        </template>
      </div>
    </div>
  </div>
</template>

<style scoped>
.block-analysis { width: 100%; }
.analysis-card { background: #fafafa; border: 1px solid #dcdfe6; border-radius: 8px; overflow: hidden; }

/* 头部样式 */
.analysis-header {
  background: #f2f6fc;
  padding: 8px 12px;
  display: flex;
  align-items: center;
  color: #409eff;
  font-size: 13px;
  font-weight: 600;
  border-bottom: 1px solid #ebeef5;
}
.header-icon { margin-right: 6px; }

/* 内容区域 */
.analysis-content { padding: 12px; }
.text-analysis { font-size: 14px; color: #444; line-height: 1.6; }

/* 间距控制 */
.mb-3 { margin-bottom: 12px; }

/* Widget 容器样式 */
.widget-wrapper {
  margin-top: 5px;
  width: 100%;             /* 占满父容器 */
  max-width: 100%;         /* 限制最大宽度 */
  overflow-x: auto;        /* 关键:允许内部横向滚动 */
  -webkit-overflow-scrolling: touch; /* 移动端顺滑滚动 */
}

/* 动画 */
.typing-dot { animation: blink 1.5s infinite; margin-left: 4px; }
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
</style>

四、扩展 Widget

到目前我们已经完成了可视化的组件渲染,但还有一部分我们没有做完。后端数据分析的结果我们目前实现了 Card、Chart、DataTable 三种生成式 UI 的下消息格式。接下来我们来设计一个可扩展的 Widget 架构,先实现这三种消息格式的展示,同时还需要支持为了可扩展的其他格式的 UI 展示,比如人机交互、表单格式。

1. 实现 Widget 渲染器

//Qjy.AICoplot.VueUI/src/components/widgets/WidgetRenderer.vue
<script setup lang="ts">
import { defineAsyncComponent } from 'vue';
import type {Widget} from "@/types/protocols.ts";

const props = defineProps<{
  data: Widget
}>();

// 异步加载组件 (Code Splitting)
// 这样做的好处是,如果用户从未收到 Chart 类型的消息,ChartWidget 的代码就不会被加载
const ChartWidget = defineAsyncComponent(() => import('./ChartWidget.vue'));
const StatsWidget = defineAsyncComponent(() => import('./StatsWidget.vue'));
const DataTableWidget = defineAsyncComponent(() => import('./DataTableWidget.vue'));

// 简单的类型映射字典 (可选,也可以用 v-if/else-if)
// 这里为了简单直观,我们在模板中直接判断
</script>

<template>
  <div class="widget-renderer">
    <ChartWidget
      v-if="data.type === 'Chart'"
      :widget="(data as any)"
    />

    <StatsWidget
      v-else-if="data.type === 'StatsCard'"
      :widget="(data as any)"
    />

    <DataTableWidget
      v-else-if="data.type === 'DataTable'"
      :widget="(data as any)"
    />

    <div v-else class="unknown-widget">
      <el-alert
        :title="`暂不支持的组件类型: ${data.type}`"
        type="warning"
        show-icon
        :closable="false"
      />
      <pre class="debug-data">{{ data }}</pre>
    </div>
  </div>
</template>

<style scoped>
.widget-renderer {
  margin-top: 10px;
  width: 100%;
}

.unknown-widget {
  margin-top: 8px;
}

.debug-data {
  background: #f4f4f5;
  padding: 8px;
  border-radius: 4px;
  font-size: 11px;
  color: #909399;
  overflow-x: auto;
}
</style>

2. 实现三种数据分析 Widget

  • 卡片展示
//Qjy.AICoplot.VueUI/src/components/widgets/StatsWidget.vue
<script setup lang="ts">
import { computed } from 'vue';
import type {StatsCardWidget} from "@/types/protocols.ts";

// 接收父组件传递的数据
const props = defineProps<{
  widget: StatsCardWidget
}>();

// 提取核心数据
const data = computed(() => props.widget.data);

// 格式化样式
const valueStyle = computed(() => ({
  color: '#303133',
  fontWeight: 'bold',
  fontSize: '24px'
}));
</script>

<template>
  <el-card shadow="hover" class="stats-card">
    <template #header>
      <div class="card-header">
        <span>{{ widget.title || data.label }}</span>
      </div>
    </template>

    <div class="card-content">
      <el-statistic
        :value="Number(data.value) || 0"
        :precision="2"
        :value-style="valueStyle"
      >
        <template #suffix>
          <span>{{ widget.data.unit }}</span>
        </template>
      </el-statistic>

    </div>
  </el-card>
</template>

<style scoped>
.stats-card {
  width: 240px; /* 固定宽度,看起来更整齐 */
  display: inline-block;
  margin-right: 12px;
  margin-bottom: 12px;
  border-radius: 8px;
}

.card-header {
  font-size: 14px;
  color: #606266;
  font-weight: 500;
}

.card-content {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
</style>
  • 数据列表展示
//Qjy.AICoplot.VueUI/src/components/widgets/DataTableWidget.vue
<script setup lang="ts">
  import { computed, ref } from 'vue';
  import type { DataTableWidget } from "@/types/protocols.ts";
  import { Filter, Setting, FullScreen } from '@element-plus/icons-vue';

  const props = defineProps<{
    widget: DataTableWidget
  }>();

  // ================== 状态管理 ==================

  // 控制列的显示/隐藏:存储当前选中的列 Key
  const visibleColumnKeys = ref<string[]>([]);

  // 初始化:默认显示所有列
  if (props.widget.data.columns) {
    visibleColumnKeys.value = props.widget.data.columns.map(c => c.key);
  }

  // 搜索关键词 (可选扩展)
  const searchQuery = ref('');

  // 控制弹出框显示
  const dialogVisible = ref(false);

  // ================== 计算属性 ==================

  /**
   * 最终用于渲染的列配置
   * 根据 visibleColumnKeys 过滤
   */
  const displayColumns = computed(() => {
    return props.widget.data.columns.filter(col => visibleColumnKeys.value.includes(col.key));
  });

  /**
   * 表格数据
   * Element Plus Table 自带排序和筛选,这里直接透传 rows
   * 如果需要前端全局搜索,可以在这里对 props.widget.data.rows 进行 filter
   */
  const tableData = computed(() => {
    let data = props.widget.data.rows;
    if (searchQuery.value) {
      const query = searchQuery.value.toLowerCase();
      data = data.filter(row =>
        Object.values(row).some(val => String(val).toLowerCase().includes(query))
      );
    }
    return data;
  });

  // ================== 辅助函数 ==================

  /**
   * 获取某一列的所有唯一值,用于生成筛选菜单
   */
  const getColumnFilters = (columnKey: string) => {
    const values = new Set(props.widget.data.rows.map(row => row[columnKey]));
    return Array.from(values).map(val => ({ text: String(val), value: val }));
  };

  /**
   * 筛选处理函数
   */
  const filterHandler = (value: string, row: any, column: any) => {
    const property = column['property'];
    return row[property] === value;
  };

  /**
   * 格式化单元格内容
   */
  const formatCell = (row: any, column: any, cellValue: any, index: number) => {
    const colDef = props.widget.data.columns.find(c => c.key === column.property);
    if (!colDef || cellValue == null) return cellValue;

    if (colDef.dataType === 'date') {
      // 简单日期格式化,实际项目中建议用 dayjs
      try {
        return new Date(cellValue).toLocaleDateString();
      } catch { return cellValue; }
    }

    if (colDef.dataType === 'number') {
      // 数字千分位
      return Number(cellValue).toLocaleString();
    }

    return cellValue;
  };

  /**
   * 打开弹出框显示全部数据
   */
  const openDataDialog = () => {
    dialogVisible.value = true;
  };
</script>

<template>
  <div class="data-table-widget">
    <div class="widget-header">
      <div class="title-area">
        <h3 class="widget-title">{{ widget.title }}</h3>
        <span class="widget-desc" v-if="widget.description">{{ widget.description }}</span>
      </div>

      <div class="actions">
        <el-input v-model="searchQuery"
                  placeholder="Search..."
                  size="small"
                  style="width: 150px; margin-right: 8px;"
                  clearable />

        <el-button :icon="FullScreen"
                   circle
                   size="small"
                   @click="openDataDialog"
                   title="弹出全部数据"
                   style="margin-right: 8px;" />

        <el-popover placement="bottom-end" :width="200" trigger="click">
          <template #reference>
            <el-button :icon="Setting" circle size="small" />
          </template>
          <div class="column-settings">
            <div class="settings-title">Visible Columns</div>
            <el-checkbox-group v-model="visibleColumnKeys" direction="vertical">
              <div v-for="col in widget.data.columns" :key="col.key" class="setting-item">
                <el-checkbox :value="col.key" :label="col.label" />
              </div>
            </el-checkbox-group>
          </div>
        </el-popover>
      </div>
    </div>

    <div class="table-content">
      <el-table :data="tableData"
                style="width: 100%"
                height="300"
                stripe
                border
                size="small">
        <el-table-column v-for="col in displayColumns"
                         :key="col.key"
                         :prop="col.key"
                         :label="col.label"
                         :min-width="120"
                         sortable
                         resizable
                         :filters="getColumnFilters(col.key)"
                         :filter-method="filterHandler"
                         :formatter="formatCell" />

        <template #empty>
          <el-empty description="No Data" :image-size="60" />
        </template>
      </el-table>
    </div>

    <!-- 弹出框显示全部数据 -->
    <el-dialog v-model="dialogVisible"
               :title="`全部数据 - ${widget.title}`"
               width="90%"
               top="5vh"
               class="full-data-dialog">
      <div class="dialog-content">
        <div class="data-summary">
          共 <strong>{{ tableData.length }}</strong> 条数据
        </div>
        <el-table :data="tableData"
                  style="width: 100%"
                  max-height="600"
                  stripe
                  border
                  size="small">
          <el-table-column v-for="col in displayColumns"
                           :key="col.key"
                           :prop="col.key"
                           :label="col.label"
                           :min-width="150"
                           sortable
                           resizable
                           :filters="getColumnFilters(col.key)"
                           :filter-method="filterHandler"
                           :formatter="formatCell" />

          <template #empty>
            <el-empty description="No Data" :image-size="60" />
          </template>
        </el-table>
      </div>

      <template #footer>
        <span class="dialog-footer">
          <el-button @click="dialogVisible = false">关闭</el-button>
        </span>
      </template>
    </el-dialog>
  </div>
</template>

<style scoped>
  .data-table-widget {
    width: 100%;
    background: #fff;
    border-radius: 8px;
    border: 1px solid #e4e7ed;
    padding: 12px;
    margin-top: 8px;
    box-shadow: 0 2px 4px rgba(0,0,0,0.05);
    display: flex;
    flex-direction: column;
  }

  .widget-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 12px;
  }

  .title-area {
    display: flex;
    flex-direction: column;
  }

  .widget-title {
    margin: 0;
    font-size: 14px;
    font-weight: 600;
    color: #303133;
  }

  .widget-desc {
    font-size: 12px;
    color: #909399;
  }

  .actions {
    display: flex;
    align-items: center;
  }

  .column-settings {
    max-height: 250px;
    overflow-y: auto;
  }

  .settings-title {
    font-size: 12px;
    font-weight: bold;
    color: #606266;
    margin-bottom: 8px;
    padding-bottom: 4px;
    border-bottom: 1px solid #EBEEF5;
  }

  .setting-item {
    margin-bottom: 4px;
  }

  .dialog-content {
    max-height: 70vh;
    overflow: hidden;
  }

  .data-summary {
    margin-bottom: 16px;
    padding: 8px 12px;
    background-color: #f5f7fa;
    border-radius: 4px;
    font-size: 14px;
    color: #606266;
  }
</style>

<style>
  .full-data-dialog .el-dialog__body {
    padding: 20px;
  }
</style>
  • Chart 图展示
//Qjy.AICoplot.VueUI/src/components/widgets/ChartWidget.vue
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue';
import * as echarts from 'echarts';
import type {ChartWidget} from "@/types/protocols.ts";

const props = defineProps<{
  widget: ChartWidget
}>();

// DOM 引用:用于挂载 Canvas
const chartRef = ref<HTMLElement | null>(null);
// ECharts 实例引用
let chartInstance: echarts.ECharts | null = null;
// ResizeObserver 实例
let resizeObserver: ResizeObserver | null = null;

// ================== 核心逻辑:数据转换 (Adapter) ==================

/**
 * 将后端 DTO 转换为 ECharts Option
 * 这是连接业务数据与可视化库的桥梁
 */
const getChartOption = () => {
  const chartType = props.widget.data.category;
  const { x, y, seriesName } = props.widget.data.encoding;
  const { dimensions, source } = props.widget.data.dataset;
  const title = props.widget.title;

  // 1. 通用基础配置
  const baseOption: any = {
    title: {
      text: title,
      left: 'center',
      textStyle: { fontSize: 14, color: '#333' }
    },
    tooltip: {
      trigger: chartType === 'Pie' ? 'item' : 'axis', // 饼图触发方式不同
      confine: true // 将 Tooltip 限制在图表容器内
    },
    legend: {
      bottom: 0, // 图例放在底部
      type: 'scroll' // 图例过多时允许滚动
    },
    grid: {
      left: '3%',
      right: '4%',
      bottom: '15%', // 留出空间给图例
      containLabel: true
    }
  };

  // 2. 根据图表类型构建配置
  // 后端返回的 chartType 首字母大写 (Bar, Line, Pie),ECharts 需要小写 (bar, line, pie)
  const typeLower = chartType.toLowerCase();

  if (chartType === 'Pie') {
    // ---- 饼图逻辑 ----
    const pieData = source.map((item: any) => ({
      name: item[x],
      value: item[y[0]] // 饼图通常只使用第一个y字段
    }));

    return {
      ...baseOption,
      tooltip: {
        ...baseOption.tooltip,
        formatter: '{a} <br/>{b}: {c} ({d}%)' // 自定义提示框格式
      },
      series: [{
        name: title,
        type: 'pie',
        radius: ['40%', '70%'], // 环形饼图
        avoidLabelOverlap: true,
        label: {
          show: true,
          formatter: '{b}: {c} ({d}%)'
        },
        emphasis: {
          label: {
            show: true,
            fontSize: '16',
            fontWeight: 'bold'
          }
        },
        data: pieData
      }]
    };
  } else {
    // ---- 柱状图 / 折线图逻辑 ----

    // 提取x轴数据
    const xAxisData = [...new Set(source.map((item: any) => item[x]))];

    // 构建系列数据
    const seriesData = y.map((yField, index) => {
      const seriesItem: any = {
        name: yField,
        type: typeLower,
        data: source.map((item: any) => item[yField])
      };

      // 柱状图特定配置
      if (chartType === 'Bar') {
        seriesItem.barWidth = '60%';
        seriesItem.itemStyle = {
          color: index === 0 ? '#5470c6' :
            index === 1 ? '#91cc75' :
              index === 2 ? '#fac858' :
                index === 3 ? '#ee6666' :
                  index === 4 ? '#73c0de' : '#3ba272',
          borderRadius: [2, 2, 0, 0] // 顶部圆角
        };

        // 渐变色效果
        if (index < 2) { // 前两个系列使用渐变色
          seriesItem.itemStyle.color = new echarts.graphic.LinearGradient(
            0, 0, 0, 1,
            [
              { offset: 0, color: index === 0 ? '#5470c6' : '#91cc75' },
              { offset: 1, color: index === 0 ? '#304c82' : '#5a8c5a' }
            ]
          );
        }
      }
      // 折线图特定配置
      else if (chartType === 'Line') {
        seriesItem.smooth = true; // 平滑曲线
        seriesItem.symbol = 'circle'; // 数据点形状
        seriesItem.symbolSize = 6;
        seriesItem.lineStyle = {
          width: 2
        };
        seriesItem.itemStyle = {
          color: index === 0 ? '#5470c6' :
            index === 1 ? '#91cc75' :
              index === 2 ? '#fac858' :
                '#ee6666'
        };

        // 面积图效果(可选)
        if (y.length === 1) { // 只有一个系列时显示面积图
          seriesItem.areaStyle = {
            opacity: 0.1
          };
        }
      }

      return seriesItem;
    });

    const option = {
      ...baseOption,
      xAxis: {
        type: 'category',
        data: xAxisData,
        axisLabel: {
          interval: 0, // 强制显示所有标签
          rotate: xAxisData.length > 6 ? 45 : 0 // 标签过多时旋转
        }
      },
      yAxis: {
        type: 'value',
        splitLine: {
          lineStyle: {
            type: 'dashed'
          }
        }
      },
      series: seriesData
    };

    // 多系列柱状图添加数据标签避免重叠
    if (chartType === 'Bar' && y.length > 1) {
      option.series.forEach((series: any, index: number) => {
        series.label = {
          show: index === 0, // 只显示第一个系列的标签
          position: 'top',
          fontSize: 12
        };
      });
    }

    return option;
  }
};

// ================== 生命周期管理 ==================

/**
 * 初始化图表
 */
const initChart = () => {
  if (!chartRef.value) return;

  // 初始化实例,并应用 light 主题
  chartInstance = echarts.init(chartRef.value, null, { renderer: 'canvas' });

  // 设置数据
  try {
    const option = getChartOption();
    chartInstance.setOption(option);
  } catch (e) {
    console.error('ECharts Option Error:', e);
  }
};

/**
 * 处理窗口大小变化
 * 使用 ResizeObserver 比 window.onresize 更精确,能监听到 div 本身的变化
 */
const setupResizeObserver = () => {
  if (!chartRef.value) return;

  // 防抖函数
  const debounce = (func: Function, delay = 150) => {
    let timeoutId: ReturnType<typeof setTimeout>;
    return (...args: any[]) => {
      clearTimeout(timeoutId);
      timeoutId = setTimeout(() => func(...args), delay);
    };
  };

  // 创建ResizeObserver实例
  resizeObserver = new ResizeObserver(debounce(() => {
    try {
      if (chartInstance?.isDisposed()) {
        resizeObserver?.unobserve(chartRef.value!);
        return;
      }

      if (chartInstance && chartRef.value) {
        const rect = chartRef.value.getBoundingClientRect();
        if (rect.width > 0 && rect.height > 0) {
          chartInstance.resize();
        }
      }
    } catch (error) {
      console.error('图表resize失败:', error);
    }
  }, 150));

  resizeObserver.observe(chartRef.value);
};

onMounted(async () => {
  // 等待 DOM 渲染完成
  await nextTick();
  initChart();
  setupResizeObserver();
});

onUnmounted(() => {
  // 销毁资源
  resizeObserver?.disconnect();
  resizeObserver = null;
  chartInstance?.dispose();
  chartInstance = null;
  chartRef.value = null; // 清理引用
});

// 监听数据变化(虽然目前是一次性渲染,但保留此逻辑支持实时更新)
watch(() => props.widget, () => {
  if (chartInstance) {
    chartInstance.setOption(getChartOption(), true); // true 表示不合并,完全重置
  }
}, { deep: true });

</script>

<template>
  <div class="chart-container">
    <div ref="chartRef" class="echarts-dom"></div>
  </div>
</template>

<style scoped>
.chart-container {
  width: 100%;
  max-width: 600px; /* 限制最大宽度,防止在大屏上太宽 */
  background: #fff;
  border-radius: 8px;
  border: 1px solid #e4e7ed;
  padding: 16px;
  margin-top: 8px;
}

.echarts-dom {
  width: 100%;
  height: 350px; /* 固定高度,确保 Canvas 有渲染空间 */
}
</style>

五、测试

1. 输出卡片

2. 输出图表

3. 输出表格