大屏可视化系统:WebRTC视频流与WebSocket实时数据集成方案

一、项目初始化与依赖配置

构建一个集成了 WebRTC 低延迟视频流与 WebSocket 实时业务数据的大屏可视化应用,首要任务是搭建一个清晰、可扩展且功能完整的开发环境。本节将基于当前(2024-2026 年)的技术实践,明确项目所需的核心技术栈、关键依赖库,并提供初始化的配置指引。

1. 技术栈选型与架构定位

在项目启动阶段,明确技术选型是奠定可扩展架构的基础。根据行业最佳实践,一个现代的大屏可视化项目通常采用分层、解耦的架构思想。

  • 前端框架与语言:推荐使用 Vue 3ReactAngular 等现代前端框架,结合 TypeScript 以获得更好的类型安全和开发体验。TypeScript 的强类型特性在管理复杂的实时数据流和组件通信时尤为重要。

  • 可视化渲染库:根据渲染需求选择:

    • Canvas 引擎:对于需要高频更新、大规模数据点渲染(如万级数据点的动态图表)的场景,EChartsVChart 是高性能的选择。Canvas 采用即时模式渲染,性能优于 SVG。

    • SVG 引擎:对于需要复杂交互、事件绑定和无损缩放的场景(如可下钻的地图),D3.js 提供了极高的灵活性。一些库如 ECharts 也支持 SVG 渲染器。

    • 3D 可视化:如需三维场景展示(如数字孪生工厂),Three.js 是标准选择。

    • 最佳实践:成熟的架构应支持双引擎或多引擎,例如在 GoView 等项目中同时集成 ECharts(复杂统计)和 VChart(轻量实时),根据场景智能选择。

  • 状态管理:对于管理跨组件的复杂共享状态(如全局筛选条件、用户信息、实时数据快照),推荐采用现代轻量级状态库。

    • Zustand:以其极简的 API(创建 Store 仅需数行代码)、约 1.2KB 的超小体积以及出色的性能(支持细粒度状态订阅)成为当前新项目的热门默认选择,能大幅提升开发效率。

    • Redux:在超大型、已有深厚积累或对时间旅行调试有强依赖的项目中仍可考虑,但其样板代码较多,包体积约 12KB。

    • 组件通信:对于跨层级、一次性的通知(如窗口缩放完成),可采用轻量级的事件总线(如 mitt)作为状态管理的补充,实现组件间松耦合通信。

  • 构建工具ViteWebpack。Vite 凭借其极快的冷启动和热更新速度,能显著提升开发体验。Webpack 的 Module Federation(模块联邦) 特性是实现前端插件化动态加载的关键技术。

  • 后端与信令服务:WebSocket 信令服务器可以使用 Node.js (ws 库)、Python (websockets 库) 或 Go 等语言快速搭建。资料中的 Python 示例展示了使用websockets库同时处理信令转发和业务数据广播。

2. 核心依赖配置详解

项目的依赖配置围绕 WebRTC 媒体通信WebSocket 实时信令 / 数据可视化渲染 以及 项目工程化 四个核心展开。

(1) WebRTC 相关依赖

WebRTC 能力由现代浏览器原生提供,无需额外安装库。核心配置在于正确初始化 RTCPeerConnection 并配置网络穿透服务器。

// 前端 WebRTC 配置示例 (基于资料中的代码模式)
const peerConnectionConfig = {
  iceServers: [
    // 公共STUN服务器,用于获取公网地址
    { urls: 'stun:stun.l.google.com:19302' },
    // 自建或第三方TURN服务器,用于在对称NAT/防火墙下中继流量(关键)
    // {
    //   urls: 'turn:your-turn-server.com:3478',
    //   username: 'username',
    //   credential: 'credential'
    // }
  ]
};
const peerConnection = new RTCPeerConnection(peerConnectionConfig);

关键点:必须配置 TURN 服务器 以确保在所有网络环境下的连通性。这是实现高可靠性的关键,否则在对称型 NAT 等复杂网络下连接会失败。

(2) WebSocket 与实时通信依赖

  • 前端:使用浏览器原生 WebSocket API 或更封装的库(如 socket.io-client)建立与信令服务器的连接。

  • 后端(信令服务器):以 Python 为例,使用 websockets 库。

    # Python 依赖
    pip install websockets

    该服务器负责:

    1. 转发 WebRTC 的 SDP Offer/Answer 和 ICE 候选信息。

    2. 广播或定向发送实时业务数据(如订单量、在线人数)。

(3) 可视化与UI依赖

  • 图表库:安装选定的可视化库。

    # 例如,使用ECharts
    npm install echarts
    # 或使用VChart
    npm install @visactor/vchart
  • UI 组件库:根据所选前端框架选择,如基于 Vue3 的 Naive UIElement Plus,或基于 React 的 Ant Design。这些组件库能加速构建大屏的控制面板、布局容器等。

(4) 工程化与架构支撑依赖

  • 状态管理:根据选型安装。

    # Zustand (React)
    npm install zustand
    # Pinia (Vue 3)
    npm install pinia
  • 插件化 / 模块化支持:如果采用微前端或插件化架构,需要配置构建工具的模块联邦能力。

  • 类型定义:为使用的库安装 TypeScript 类型定义文件(如 @types/websocket)。

3. 项目初始化与环境搭建步骤

  1. 创建项目脚手架:使用框架官方 CLI 工具(如 create-vuecreate-react-app)或基于 Vite 模板初始化项目。

  2. 安装核心依赖:根据上述选型,通过包管理器(npm/yarn/pnpm)一次性安装所有确定的依赖。

  3. 配置构建工具:在 vite.config.tswebpack.config.js 中,配置别名(alias)、代理(proxy)以方便开发,并为生产环境优化(代码分割、压缩)。

  4. 设置目录结构:采用模块化设计,创建清晰的目录,例如:

    src/
    ├── assets/           # 静态资源
    ├── components/       # 通用组件
    │   ├── charts/      # 图表组件(封装ECharts等)
    │   ├── layout/      # 布局组件
    │   └── ...
    ├── composables/     # Vue组合式函数 (或 React hooks)
    ├── stores/          # 状态管理 (Pinia/Zustand stores)
    ├── plugins/         # 插件或可动态加载的模块
    ├── views/           # 页面视图
    ├── utils/           # 工具函数
    ├── types/           # TypeScript类型定义
    └── main.ts          # 应用入口
  5. 配置主题与样式系统:建立基于 CSS 变量或 Sass/Less 的全局主题系统,集中管理颜色、字体、间距等设计令牌(Design Tokens),确保所有可视化组件风格一致。

  6. 初始化通信模块:创建 websocket.service.tswebrtc.service.ts 等文件,封装 WebSocket 连接管理、消息分发和 WebRTC PeerConnection 的创建、信令交换等通用逻辑,实现与业务组件的解耦。

通过以上步骤,一个兼顾功能完整性、代码清晰度和未来可扩展性的大屏可视化项目基础环境便搭建完成,为后续集成低延迟视频流与实时数据打下了坚实的技术地基。

二、可扩展架构设计

面向 2024-2026 年的大屏可视化项目,其架构设计的核心目标是构建一个能够从容应对数据量增长、业务需求频繁变化以及多场景灵活部署的系统。基于分层解耦与配置驱动的思想,本项目的可扩展架构旨在将WebRTC 低延迟视频流WebSocket 实时业务数据多引擎可视化渲染统一状态管理以及插件化动态扩展等核心能力有机整合,形成一个高内聚、低耦合、易于维护和扩展的技术体系。

一、 分层解耦与配置驱动架构

现代大屏系统正从“硬编码”向“配置化”演进。本架构采用清晰的分层设计,将系统解耦为可视化层、布局层、数据层、主题层和工具层,每一层均可独立演进。

  • 配置驱动的布局与渲染:布局层采用基于JSON Schema 的配置来描述大屏的网格结构、响应式断点规则和组件位置。这使得非技术人员可通过修改配置文件(而非代码)来调整大屏的整体排版与组件排布,实现了极高的灵活性。可视化层支持ECharts 与 VChart 双引擎,可根据场景智能选择或指定:ECharts 适用于组件丰富的复杂统计图表,而 VChart 在轻量化和大屏实时数据流渲染方面表现更优。渲染引擎的选择策略(如根据数据量阈值自动切换)本身也可作为配置项。

  • 统一数据平台与前端解耦:为解决多源(WebSocket 业务数据、WebRTC 视频流、API)数据“衔接断层”的问题,架构中引入了一个逻辑上的统一数据适配层。该层负责对接所有原始数据源,通过预定义的数据适配器(Adapter) 进行清洗、格式转换与融合,形成前端可视化组件可直接消费的统一数据格式。同时,通过拦截器(Interceptor) 为所有数据请求添加统一的认证、错误处理与日志逻辑。

二、 插件化动态扩展机制

插件化是支撑业务灵活性和技术栈解耦的核心。本架构参考微前端与模块联邦思想,实现前端功能的“热插拔”。

  1. 插件定义与封装:每个功能模块(如一个特殊图表、一个 3D 模型组件、一个数据源处理器)均可封装为独立插件。插件是一个独立的模块包,包含其完整的视图、逻辑与样式,并对外暴露标准的元数据接口(如pluginCodeversionentryUrl)。

  2. 动态加载与渲染:主程序作为轻量级容器,维护一个插件注册中心。当需要加载某个插件时,根据其entryUrl,利用 Webpack Module FederationVite 的动态导入能力远程加载模块代码。加载成功后,利用框架的动态组件能力(如 Vue 的defineAsyncComponent)进行实例化与渲染。

  3. 开放的数据与事件协议:为确保插件与主程序及其他插件协同工作,定义了开放的通信协议。

    • 数据协议:主程序通过 Props 或 Context 向插件注入统一处理后的数据。插件也可按协议主动请求数据。

    • 事件交互协议:建立轻量级事件总线(Event Bus),用于处理跨插件、非父子关系的解耦通信。例如,一个 3D 场景插件可以抛出modelClicked事件,携带设备 ID,而一个图表插件监听此事件并更新为对应设备的数据。这避免了组件间的直接依赖,实现了松耦合联动。

三、 状态管理与组件通信设计

复杂的大屏状态需要可预测、可调试的管理方案。综合当前最佳实践,本架构优先采用Zustand作为核心状态管理库。

  • 选型依据:Zustand 以其极简的 API(创建 Store 仅需数行代码)、出色的性能(约 1.2KB 体积,支持细粒度状态订阅)和平缓的学习曲线,成为大多数大屏项目的优选。它避免了 Redux 的冗长样板代码,能更高效地管理全局主题、用户筛选条件、实时数据快照等共享状态。

  • 混合通信模式:采用 “状态管理为主,事件总线为辅” 的混合模式。

    • 复杂共享状态:如全局筛选条件、用户权限、实时数据看板的核心指标,由 Zustand Store 集中管理,保证单一数据源和可预测的更新。

    • 一次性、解耦的通知:如窗口缩放完成、某个动画播放完毕、跨层级组件的简单消息传递,则通过事件总线进行发布 / 订阅。这既保持了相关组件的独立性,又满足了通信需求。

  • 状态结构设计:状态按业务领域(如“视频监控”、“业务概览”、“实时预警”)而非技术类型进行组织,提升可维护性。为派生数据使用记忆化选择器(Memoized Selectors) 优化性能。

四、 数据流与渲染性能优化架构

可扩展性必须建立在稳定的性能基础之上。架构在数据流与渲染层面内置了优化策略。

  • 智能渲染引擎选择:根据数据规模与交互需求,在CanvasSVG间做出智能决策或混合使用。Canvas 采用即时模式渲染,适用于高频更新、大规模点阵(如万级数据点的动态热力图);SVG 每个元素为独立 DOM 节点,适用于需要复杂交互、事件绑定和无损缩放的图表(如可下钻地图)。此选择逻辑可配置化。

  • 数据分片与按需加载:面对超大规模场景(如数字孪生工厂),采用分层渲染与数据分片加载策略。将场景按“园区 - 车间 - 产线”层级配置化建模,仅动态加载当前视图层级所需精度的模型与数据,将加载时间从数十秒压缩至数秒内。

  • 实时数据流治理:对 WebSocket 推送的实时业务数据,在数据适配层进行去重、节流与聚合处理,避免前端图表不必要的频繁重绘。同时,为视频流与业务数据设计帧级同步机制(如利用 WebRTC 的 SEI 补充增强信息注入 JSON 元数据),确保视频画面与叠加的业务指标(如订单量)在时间上绝对同步。

五、 主题、样式与多端适配体系

为保障视觉一致性与跨端体验,建立统一的主题与适配系统。

  • 全局主题系统:使用CSS 变量(Custom Properties)Sass/Less 设计令牌(Design Tokens) 集中管理颜色、字体、间距、圆角等视觉要素。所有组件样式均基于这些变量构建,实现亮色 / 暗色主题的一键切换。

  • 响应式与多端适配:除了传统的 CSS 媒体查询,针对从 4K 大屏到桌面显示器的不同分辨率,架构提供多种适配方案配置。核心是结合使用CSS Flex/Grid 布局与基于 JavaScript 的等比缩放控制器,并允许为不同宽高比预设多套布局配置,确保可视化内容在任何屏幕上都能清晰、完整地展示。

通过以上五个维度的设计,本架构构建了一个以配置驱动为灵魂、以插件化为扩展手段、以高性能数据流与渲染为基石、以统一状态与主题为纽带的可扩展大屏可视化系统。它不仅能满足当前 WebRTC 视频与 WebSocket 数据实时可视化的需求,更为未来新增数据源、可视化形式或交互模式提供了清晰、低成本的集成路径。

三、WebRTC 低延迟视频流接入

在大屏可视化场景中,实时视频流(如监控画面、实时渲染视图)的接入要求毫秒级的端到端延迟,以保障监控与决策的即时性。WebRTC(Web 实时通信)技术凭借其基于 UDP 的传输和内置的 NAT 穿透能力,成为实现这一目标的核心技术标准。本章将基于双协议协同架构,详细阐述如何将 WebRTC 低延迟视频流稳定、高效地接入大屏可视化系统。

3.1 架构与协议协同:WebSocket 信令 + WebRTC 媒体

实现低延迟视频流接入的核心在于采用 “信令与控制分离,媒体与数据并行” 的混合架构。该架构充分发挥了不同协议的优势:

  • WebSocket 作为可靠信令与控制通道:基于 TCP,提供有序、可靠的双向通信。在本系统中,它主要负责:

    • 信令交换:在 WebRTC 连接建立前,客户端与信令服务器通过 WebSocket 交换 SDP Offer/Answer 和 ICE 候选信息。

    • 会话管理:处理房间加入、离开,以及视频源的选择与切换指令。

    • 轻量控制信令:传输如播放、暂停、清晰度切换等控制命令。

  • WebRTC 作为低延迟媒体流通道:基于 UDP,专为实时音视频传输设计,致力于实现点对点(P2P)或经由媒体服务器(如 SFU)的超低延迟流传输。其RTCPeerConnection API 是建立连接并接收 / 发送媒体流的基石。

这种分工确保了信令的可靠性,同时让媒体流享有最低的网络传输延迟。

3.2 实现步骤与代码封装

接入流程可分为初始化、信令协商、媒体流处理三个阶段。我们将基于前序架构中预留的 src/services/webrtc.service.ts 进行具体实现封装。

1. 服务初始化与配置 首先,创建 WebRTC 服务类,配置 ICE 服务器以保障在各类网络环境下的连通性。公共 STUN 服务器用于获取公网地址,TURN 服务器则在对称型 NAT 等复杂环境下提供中继后备。

// src/services/webrtc.service.ts
export class WebRTCService {
  private peerConnection: RTCPeerConnection | null = null;
  private signalingSocket: WebSocket; // 假设已通过WebSocket服务注入

  // ICE服务器配置(与前序配置一致)
  private readonly rtcConfig: RTCConfiguration = {
    iceServers: [
      { urls: 'stun:stun.l.google.com:19302' }, // 公共STUN
      { 
        urls: 'turn:your-turn-server.com:3478', // 预留TURN服务器地址
        username: 'your-username',
        credential: 'your-credential'
      }
    ]
  };

  constructor(signalingService: any) {
    this.signalingSocket = signalingService.getSocket(); // 获取已建立的WebSocket连接
    this.setupSignalingHandlers();
  }
}

2. 建立连接与信令交换 当大屏需要订阅某个视频源时,发起端创建RTCPeerConnection,并通过 WebSocket 交换 SDP 和 ICE 候选。

// 在 WebRTCService 类中
public async startConnection(streamId: string): Promise<void> {
  // 1. 创建PeerConnection实例
  this.peerConnection = new RTCPeerConnection(this.rtcConfig);

  // 2. 处理ICE候选,并通过WebSocket发送给信令服务器
  this.peerConnection.onicecandidate = (event) => {
    if (event.candidate) {
      this.signalingSocket.send(JSON.stringify({
        type: 'webrtc_signal',
        target: streamId, // 指定目标视频源或信令服务器
        signal: { iceCandidate: event.candidate }
      }));
    }
  };

  // 3. 处理接收到的远端媒体流,并注入到统一数据适配层
  this.peerConnection.ontrack = (event) => {
    const remoteStream = event.streams[0];
    // 关键:将原始MediaStream传递给统一数据适配层进行处理
    window.dispatchEvent(new CustomEvent('webrtc-stream-received', { 
      detail: { streamId, mediaStream: remoteStream }
    }));
    // 同时,也可直接绑定到video元素进行预览(如需要)
    const videoElement = document.getElementById(`video-${streamId}`) as HTMLVideoElement;
    if (videoElement && videoElement.srcObject !== remoteStream) {
      videoElement.srcObject = remoteStream;
    }
  };

  // 4. 创建Offer,设置本地描述,并发送
  try {
    const offer = await this.peerConnection.createOffer();
    await this.peerConnection.setLocalDescription(offer);
    this.signalingSocket.send(JSON.stringify({
      type: 'webrtc_signal',
      target: streamId,
      signal: { sdp: this.peerConnection.localDescription }
    }));
  } catch (error) {
    console.error('创建Offer失败:', error);
  }
}

// 处理从WebSocket收到的远端信令(Answer或ICE候选)
private async handleRemoteSignal(signal: any): Promise<void> {
  if (!this.peerConnection) return;

  if (signal.sdp) {
    const remoteDesc = new RTCSessionDescription(signal.sdp);
    await this.peerConnection.setRemoteDescription(remoteDesc);
    // 如果收到的是Offer(作为接收端),则需要创建Answer
    if (signal.sdp.type === 'offer') {
      const answer = await this.peerConnection.createAnswer();
      await this.peerConnection.setLocalDescription(answer);
      this.signalingSocket.send(JSON.stringify({
        type: 'webrtc_signal',
        signal: { sdp: this.peerConnection.localDescription }
      }));
    }
  } else if (signal.iceCandidate) {
    await this.peerConnection.addIceCandidate(new RTCIceCandidate(signal.iceCandidate));
  }
}

注:以上代码展示了发起连接的核心逻辑。在实际的插件化架构中,信令服务器需正确路由消息至对应的对等端或 SFU 媒体服务器。

3. 媒体处理与性能优化 为了确保大屏显示的流畅与清晰,需在编码和渲染环节进行优化:

  • 硬件加速解码:浏览器会自动优先使用硬件解码,对于容器化应用,需确保 GPU 透传(如 Docker 中映射/dev/dri设备)。

  • 高效渲染绑定:直接将MediaStream对象赋值给<video>元素的srcObject属性,这是性能最佳的方式。

    videoElement.srcObject = mediaStream; // 正确做法
    // 避免使用已废弃的 URL.createObjectURL(stream)
  • 自适应码率与抗弱网:依赖RTCPeerConnection内置的拥塞控制(如 GCC 算法),并根据接收到的 RTCP 反馈(可通过peerConnection.getStats()获取)来驱动前端的降级策略(如提示网络状况)。

3.3 数据同步与扩展应用

纯视频流之外,WebRTC 还为业务数据同步提供了强大扩展能力。

  1. RTCDataChannel 用于低延迟业务数据: 对于需要与视频流严格同步的高频、低延迟控制指令或元数据(如远程操控 3D 模型视角),可以创建RTCDataChannel。它与媒体流共享同一个传输通道,延迟极低。

    const dataChannel = this.peerConnection.createDataChannel('opsData');
    dataChannel.onmessage = (event) => {
      const opsCommand = JSON.parse(event.data);
      // 处理业务操作命令,如更新图表筛选条件
      window.dispatchEvent(new CustomEvent('rtc-data-command', { detail: opsCommand }));
    };
  2. SEI(补充增强信息)实现帧级同步: 对于需要将元数据(如物体识别框、传感器读数)与特定视频帧精准对齐的场景,SEI 技术是终极方案。元数据被直接注入视频编码层的 NAL 单元,随帧传输和解码,实现“神同步”。

    • 注入端:在视频编码时,将 JSON 格式的元数据(如{objectId: 123, x: 100, y: 200})作为 SEI 信息插入。

    • 解析端:在大屏播放端,从解码后的视频帧中提取 SEI 数据,并驱动可视化组件(如 Canvas 叠加层)进行实时绘制。此部分通常需要额外的解码库或播放器支持(如 WebCodecs API)。

通过上述步骤,WebRTC 视频流被成功接入并注入统一数据适配层。视频流本身作为一类特殊的“数据源”,与通过 WebSocket 接入的业务数据源(订单量、在线人数)一同,为后续的可视化组件渲染提供了实时、低延迟的输入。

四、WebSocket 实时业务数据接入

在“信令与控制分离,媒体与数据并行”的双协议协同架构中,WebSocket 扮演着可靠的信令与控制通道角色。它基于 TCP,提供有序、可靠的双向通信,完美承接了前序架构设计中已就绪的信令通道职责,专门用于传输业务运营数据、控制指令及 WebRTC 建立连接所需的信令,与专司低延迟媒体流的 WebRTC 各司其职。

🔌 协议设计与数据流

本系统采用统一的 JSON 消息格式在 WebSocket 通道上进行通信,格式约定为 { type, target, payload },这与前序架构中约定的信令格式一致,确保了协议的统一性。

  • type: 消息类型,用于在统一数据适配层进行路由和分类处理。例如:

    • webrtc_signal: WebRTC 信令(SDP Offer/Answer, ICE 候选)。

    • business_data: 实时业务数据(如订单量、在线人数)。

    • control_command: 对大屏或视频源的控制指令。

  • target: 消息目标,用于在广播场景下指定接收方,或用于区分不同的数据流。

  • payload: 消息有效载荷,其结构根据 type 不同而变化。

所有通过 WebSocket 接收的原始数据,均会流入前文已定义的统一数据适配层。该层作为数据枢纽,负责对原始业务数据进行清洗、格式转换与治理,然后注入 Zustand 全局状态库,驱动可视化组件更新。

💻 前端实现:连接、监听与状态注入

前端通过已封装的 WebSocket 服务(如 src/services/websocket.service.ts)建立连接,并监听各类消息。

// 前端示例:建立连接与消息分发
class WebSocketService {
  constructor(url) {
    this.socket = new WebSocket(url);
    this.setupEventListeners();
  }

  setupEventListeners() {
    this.socket.onopen = () => {
      console.log('WebSocket连接已建立,可进行身份认证或订阅');
      // 触发连接成功事件,供其他模块响应
      eventBus.emit('WS_CONNECTION_ESTABLISHED');
    };

    this.socket.onmessage = async (event) => {
      try {
        const rawData = JSON.parse(event.data);
        // 将原始数据送入统一数据适配层进行处理
        const processedData = await dataAdapter.process(rawData);
        
        // 根据处理后的数据类型,更新对应的 Zustand Store 或触发事件
        switch(processedData.type) {
          case 'business_data':
            // 更新业务数据Store,例如更新订单量、在线人数
            useRealtimeDashboardStore.getState().updateMetrics(processedData.payload);
            // 同时发布事件,供插件化组件订阅
            eventBus.emit('BUSINESS_DATA_UPDATED', processedData.payload);
            break;
          case 'control_command':
            // 执行控制命令,如切换视图、布局
            executeControlCommand(processedData.payload);
            eventBus.emit('CONTROL_COMMAND_RECEIVED', processedData.payload);
            break;
          case 'webrtc_signal':
            // 将WebRTC信令传递给WebRTC管理模块
            webRTCManager.handleSignal(processedData);
            break;
        }
      } catch (error) {
        console.error('WebSocket消息处理失败:', error);
        // 触发错误事件,可由全局拦截器处理
        eventBus.emit('DATA_PROCESSING_ERROR', { error, rawData: event.data });
      }
    };

    this.socket.onerror = (error) => {
      console.error('WebSocket错误:', error);
      eventBus.emit('WS_CONNECTION_ERROR', error);
    };
  }

  send(data) {
    if (this.socket.readyState === WebSocket.OPEN) {
      this.socket.send(JSON.stringify(data));
    }
  }
}

🖥️ 后端实现:广播与定向转发

后端 WebSocket 服务器(例如使用 Python websockets 库)承担着连接管理、消息广播和信令转发的核心任务。

# Python后端示例 (简化核心逻辑)
import asyncio, websockets, json
from typing import Set

class SignalingServer:
    def __init__(self):
        self.connected_clients = {}  # client_id: websocket
        self.rooms = {}  # room_id: Set[client_id]

    async def register(self, websocket, client_id):
        """客户端注册"""
        self.connected_clients[client_id] = websocket
        print(f"客户端 {client_id} 已连接")

    async def broadcast_business_data(self):
        """模拟广播业务数据(如从消息队列Kafka中读取)"""
        while True:
            if self.connected_clients:
                # 模拟获取实时业务数据
                mock_data = {
                    "type": "business_data",
                    "payload": {
                        "timestamp": time.time(),
                        "orders_last_minute": random.randint(100, 200),
                        "online_users": random.randint(5000, 6000),
                        "gmv": random.uniform(100000, 200000)
                    }
                }
                message = json.dumps(mock_data)
                # 广播给所有连接的客户端(或特定房间)
                tasks = [client.send(message) for client in self.connected_clients.values()]
                await asyncio.gather(*tasks, return_exceptions=True)
            await asyncio.sleep(1)  # 每秒广播一次

    async def handler(self, websocket, path):
        """处理客户端连接"""
        client_id = await self.authenticate(websocket)  # 身份认证
        await self.register(websocket, client_id)
        try:
            async for message in websocket:
                data = json.loads(message)
                # 1. 业务数据请求:直接回复或广播
                if data.get('type') == 'subscribe_metrics':
                    await self.handle_subscription(websocket, data)
                # 2. WebRTC信令:根据target进行点对点转发
                elif data.get('type') == 'webrtc_signal':
                    target_client = self.connected_clients.get(data['target'])
                    if target_client:
                        await target_client.send(json.dumps(data))
                # 3. 控制命令:广播给所有大屏或特定组
                elif data.get('type') == 'control_command':
                    await self.broadcast_to_dashboard_clients(data)
        finally:
            # 连接断开处理
            self.connected_clients.pop(client_id, None)
            print(f"客户端 {client_id} 已断开")

async def main():
    server = SignalingServer()
    start_server = websockets.serve(server.handler, "0.0.0.0", 8765)
    # 并行运行服务器和业务数据广播任务
    await asyncio.gather(start_server, server.broadcast_business_data())

🛡️ 实时数据治理与性能保障

为应对高频率数据推送可能带来的前端性能与体验问题,统一数据适配层内置了关键的数据治理策略:

策略

目的

实现方式

去重 (Deduplication)

避免因网络抖动导致重复消息造成界面不必要的渲染。

为每条消息添加唯一序列号或时间戳,在适配层缓存近期消息 ID 进行过滤。

节流 (Throttling)

防止高频数据(如每秒百次传感器读数)压垮前端渲染。

例如,无论后端每秒推送多少次,适配层保证最多每 100 毫秒向 Store 提交一次数据更新。

聚合 (Aggregation)

将细粒度数据聚合成有业务意义的指标。

在适配层内对原始流水数据进行累加、求平均等计算,再输出聚合后的结果(如“过去 10 秒平均订单速率”)。

差值更新

减少传输数据量,仅发送变化的部分。

后端仅推送变化的字段,适配层负责将增量更新合并到完整的本地状态中。

🔗 与状态管理及事件总线的集成

经过适配层处理后的纯净业务数据,被注入到对应的 Zustand Store 中。例如,实时运营指标会更新 useRealtimeDashboardStore。同时,适配层或服务层会通过轻量级事件总线(mitt)发布相应的事件,例如 BUSINESS_DATA_UPDATED。这使得:

  1. 可视化组件:通过订阅 Store 或监听事件,实现数据的响应式渲染。

  2. 插件化模块:可以通过事件总线订阅 BUSINESS_DATA_UPDATED 事件,在无需修改核心代码的情况下,对数据做出自定义响应或渲染。

  3. 控制指令:通过 WebSocket 接收的 control_command 类型消息,经适配层转换后,可直接调用相关函数或发布如 LAYOUT_CHANGE_REQUESTED 事件,由布局管理模块响应执行。

至此,实时业务数据通过 WebSocket 通道稳定接入,并经由统一数据适配层的治理,被安全、高效地分发至整个应用的状态管理与组件渲染体系,为最终的大屏可视化呈现提供了动态的数据血液。

五、大屏可视化组件实现

本章将基于前文构建的统一数据流与可扩展架构,具体阐述大屏可视化组件的实现模式。核心目标是构建一个配置驱动、高性能、可热插拔的组件生态系统,将接入的实时视频流与业务数据转化为直观、动态的视觉洞察。

一、配置驱动与声明式组件架构

现代大屏开发已从硬编码转向配置驱动开发(CDD),将界面布局、数据绑定与交互逻辑抽象为可配置的元数据,实现快速迭代与交付。

  1. 分层配置模型:组件实现严格遵循分层架构。

    • 布局层:大屏的整体结构与组件位置由一份 JSON Schema 定义。该配置描述画布网格、响应式断点以及每个可视化单元(如图表、视频窗口、指标卡)的坐标、尺寸和层级关系。

    • 组件层:每个可视化单元(如一个折线图)是一个独立的、可配置的模块。其所有可变属性(数据源 ID、图表类型、颜色、标题等)均通过 Props 或一个配置对象(options)注入,实现 “容器与内容分离”

    • 数据层:组件所需的数据源在配置中通过唯一标识(如 dataSourceId: “realtime_orders”)声明。组件内部不关心数据来自 WebSocket 还是 WebRTC,它只消费经由统一数据适配层处理后的、格式规范的数据流。

    • 主题层:视觉样式(色彩、字体、间距等)通过全局的 CSS 变量(Design Tokens) 或 Sass 变量管理。组件样式全部基于这些主题变量编写,支持一键切换亮 / 暗主题。

  2. 原子化与复用:基于 Vue 3/React 构建基础图表组件库。每个组件(如<BaseChart />)是自包含的,封装自身的渲染、resize 和销毁逻辑。通过组合和配置这些原子组件,可以快速搭建复杂的业务大屏。

二、状态管理:Zustand为核心,事件总线为补充

为管理复杂的全局状态(如筛选条件、主题模式、用户权限)并实现高效组件通信,采用混合模式。

  1. Zustand 作为中央状态库:对于需要跨多个组件共享且关系复杂的应用状态,使用 Zustand 创建 Store。其极简 API(约 1.2KB)和细粒度状态订阅能力,能精准控制组件重渲染,性能优异。例如,useDashboardStore 可以管理全局的筛选时间范围、高亮的数据维度等。

  2. 事件总线处理解耦通信:对于一次性、跨层级、非父子关系的组件间通知(如图表点击触发地图下钻、视频播放完成通知),使用轻量级事件总线(如 mitt)。这实现了组件间的松耦合。例如,一个深层的 3D 模型插件可以抛出 modelClicked 事件,由顶层的控制面板监听并响应,而两者无需直接引用。

  3. 数据流:WebSocket 推送的业务数据经适配层处理后,更新至 Zustand Store。图表组件通过 Selector 订阅 Store 中其关心的数据片段。当用户通过筛选器交互改变状态时,Store 更新,所有相关图表自动重绘。同时,可通过事件总线广播状态变更事件,供不直接依赖该状态但需响应的组件使用。

三、插件化架构与动态加载

为实现功能的“热插拔”与团队并行开发,采用基于模块联邦(Module Federation)或动态导入的插件化架构。

  1. 插件定义:每个可视化组件(如一个自定义的 3D 地球、一个特殊的甘特图)可打包为独立的插件模块。插件包需导出约定的接口,至少包含唯一pluginCode、版本version和主入口组件。

  2. 注册与加载:主程序维护一个插件注册中心(远程或本地配置)。当需要渲染某个组件时,根据其pluginCode从注册中心获取插件模块的入口地址(entryUrl),然后通过动态import()或模块联邦的loadRemoteModule方法异步加载。

  3. 渲染与通信:插件加载成功后,主程序将其渲染到画布指定位置,并通过 Props/Context 向其注入统一的数据、主题和事件总线实例。插件内部可以独立运行其逻辑,并通过事件总线与外界通信。

四、双引擎可视化支持与渲染策略

为平衡渲染性能与交互灵活性,支持 Canvas 与 SVG 双渲染引擎,并根据场景智能选择。

  1. ECharts 与 VChart 双引擎

    • ECharts:用于组件丰富、交互复杂的统计分析图表(如关系图、自定义系列)。

    • VChart:针对大屏实时数据刷新场景优化,在轻量化和高频更新方面表现更佳。

    • 组件配置中可声明渲染引擎偏好,由主程序统一调度资源。

  2. 智能渲染策略

    • Canvas 渲染:默认用于高频更新(如实时折线图)或数据量极大(万级节点)的场景,利用其即时模式渲染的优势保证性能。

    • SVG 渲染:用于需要复杂 DOM 交互(如精确点击、鼠标悬停提示)、无损缩放(如可下钻的地图)的组件。

    • 系统可根据数据量阈值或组件类型配置,自动或手动指定渲染模式。

五、视频与数据叠加组件的帧级同步

对于需要将业务数据(如订单热区、在线人数标签)叠加到 WebRTC 视频流上的场景,实现精准同步至关重要。

  1. SEI(补充增强信息)方案:利用 WebRTC 的 SEI 特性,将元数据(如 JSON 格式的物体坐标、指标数值)在编码端直接注入视频帧。接收端解码时,同步解析 SEI 数据,并调用 Canvas API 在<video>元素上实时绘制叠加层(如框、线、文字)。这确保了数据与视频画面的帧级同步,实现“零延迟”叠加。

  2. Canvas 叠加绘制:在播放视频的 Canvas 或叠加的 Canvas 层上,使用requestAnimationFrame进行循环绘制。绘制数据来源于:

    • 解析视频流中的 SEI 信息。

    • 通过 RTCDataChannel 接收的、与视频流时间戳对齐的控制指令。

    • 从 Zustand Store 中获取的、经过去重和节流处理的实时业务指标。

六、主题、响应式与性能优化

  1. 全局主题系统:所有组件样式基于一套CSS 自定义属性(变量) 定义。通过修改根元素的 CSS 变量,可实现整个大屏主题的一键切换。插件在开发时也必须遵循此主题变量体系。

  2. 响应式与自适应布局

    • 采用 CSS Flex/Grid 结合 JavaScript 等比缩放 的策略。布局配置(JSON Schema)中定义不同屏幕断点下的组件排列规则。

    • 监听 resize 事件,通过事件总线通知所有插件组件进行自适应调整。

  3. 组件级性能优化

    • 虚拟滚动与分片加载:对超长列表或海量点图,在组件内部实现虚拟滚动或数据分片渲染。

    • 按需渲染:对非可视区域或折叠状态的组件,停止其数据订阅与动画渲染。

    • 图表配置优化:关闭非必要的动画特效,对大数据集启用 dataZoom 或采样。

通过以上实现,大屏可视化组件成为一个高度模块化、可配置、可扩展的有机整体。它们消费统一的实时数据流,遵循一致的状态与通信规范,并能根据业务需求动态组合与替换,最终构建出既能“一眼看懂”业务全局,又能通过交互“深入洞察”的智能可视化界面。

六、完整可运行代码示例

本章将整合前文所述的所有技术要点,提供一个可直接运行的前端大屏可视化程序示例。该示例基于 Vue 3 + TypeScript + Vite 技术栈,完整实现了 WebRTC 低延迟视频流接入、WebSocket 实时业务数据接收、以及使用 ECharts 的动态数据可视化。

项目结构与核心文件

src/
├── main.ts                      # 应用入口
├── App.vue                      # 根组件
├── index.html
├── vite.config.ts               # Vite 配置
├── styles/
│   └── global.css               # 全局样式与主题变量
├── services/
│   ├── websocket.service.ts     # WebSocket 服务封装
│   └── webrtc.service.ts        # WebRTC 服务封装
├── stores/
│   └── dashboard.store.ts       # Zustand 状态管理
├── components/
│   ├── VideoStream.vue          # 视频流展示组件
│   ├── DataDashboard.vue        # 数据可视化仪表盘组件
│   └── LayoutContainer.vue     # 大屏布局容器
└── plugins/
    └── echarts.plugin.ts        # ECharts 插件注册与主题适配

1. 全局配置与依赖 (vite.config.ts & styles/global.css)

vite.config.ts

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
  server: {
    port: 5173,
    host: true,
  },
  build: {
    target: 'es2020',
    rollupOptions: {
      output: {
        manualChunks: {
          echarts: ['echarts'],
          'vue-router': ['vue-router'],
        },
      },
    },
  },
})

styles/global.css

:root {
  /* 设计令牌 (Design Tokens) */
  --primary-color: #0052d9;
  --secondary-color: #00a870;
  --warning-color: #ff7d00;
  --error-color: #f53f3f;
  --bg-color: #0e1621;
  --card-bg-color: #1c2532;
  --text-color-primary: #e5e6eb;
  --text-color-secondary: #86909c;
  --border-radius-base: 6px;
  --font-size-base: 14px;
  --spacing-base: 16px;

  /* 大屏布局相关 */
  --header-height: 60px;
  --sidebar-width: 300px;
}

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
  font-size: var(--font-size-base);
  color: var(--text-color-primary);
  background-color: var(--bg-color);
  overflow: hidden; /* 大屏通常全屏,隐藏滚动条 */
}

#app {
  width: 100vw;
  height: 100vh;
}

2. 核心服务实现

services/websocket.service.ts

import mitt, { Emitter } from 'mitt'

type WebSocketMessage = {
  type: 'business_data' | 'webrtc_signal' | 'control_command'
  target?: string
  payload: any
}

type WebSocketEvents = {
  WS_CONNECTION_ESTABLISHED: void
  BUSINESS_DATA_UPDATED: { orders: number; onlineUsers: number; timestamp: number }
  webrtc_signal_received: any
  connection_error: Error
}

export class WebSocketService {
  private socket: WebSocket | null = null
  private eventBus: Emitter<WebSocketEvents> = mitt<WebSocketEvents>()
  private reconnectAttempts = 0
  private readonly maxReconnectAttempts = 5
  private reconnectTimeout: number | null = null

  constructor(private url: string = 'ws://localhost:8765') {}

  connect(): Promise<void> {
    return new Promise((resolve, reject) => {
      if (this.socket?.readyState === WebSocket.OPEN) {
        resolve()
        return
      }

      try {
        this.socket = new WebSocket(this.url)
      } catch (error) {
        reject(error)
        return
      }

      this.socket.onopen = () => {
        console.log('WebSocket连接已建立')
        this.reconnectAttempts = 0
        this.eventBus.emit('WS_CONNECTION_ESTABLISHED')
        resolve()
      }

      this.socket.onmessage = (event) => {
        try {
          const data: WebSocketMessage = JSON.parse(event.data)
          this.handleMessage(data)
        } catch (error) {
          console.error('解析WebSocket消息失败:', error)
        }
      }

      this.socket.onerror = (error) => {
        console.error('WebSocket错误:', error)
        this.eventBus.emit('connection_error', new Error('WebSocket连接错误'))
      }

      this.socket.onclose = (event) => {
        console.log(`WebSocket连接关闭,代码: ${event.code}, 原因: ${event.reason}`)
        this.attemptReconnect()
      }
    })
  }

  private handleMessage(data: WebSocketMessage): void {
    switch (data.type) {
      case 'business_data':
        // 假设 payload 格式为 { orders: number, onlineUsers: number }
        this.eventBus.emit('BUSINESS_DATA_UPDATED', data.payload)
        break
      case 'webrtc_signal':
        this.eventBus.emit('webrtc_signal_received', data.payload)
        break
      case 'control_command':
        console.log('收到控制命令:', data.payload)
        // 可根据命令类型分发到不同处理器
        break
      default:
        console.warn('未知消息类型:', data.type)
    }
  }

  send(data: object): void {
    if (this.socket?.readyState === WebSocket.OPEN) {
      this.socket.send(JSON.stringify(data))
    } else {
      console.warn('WebSocket未连接,消息发送失败:', data)
    }
  }

  private attemptReconnect(): void {
    if (this.reconnectAttempts >= this.maxReconnectAttempts) {
      console.error('达到最大重连次数,停止重连')
      return
    }

    if (this.reconnectTimeout) {
      clearTimeout(this.reconnectTimeout)
    }

    const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000)
    this.reconnectAttempts++

    console.log(`将在 ${delay}ms 后尝试第 ${this.reconnectAttempts} 次重连...`)
    this.reconnectTimeout = window.setTimeout(() => {
      this.connect().catch((err) => console.error('重连失败:', err))
    }, delay)
  }

  getEventBus(): Emitter<WebSocketEvents> {
    return this.eventBus
  }

  disconnect(): void {
    if (this.reconnectTimeout) {
      clearTimeout(this.reconnectTimeout)
      this.reconnectTimeout = null
    }
    if (this.socket) {
      this.socket.close(1000, '客户端主动断开')
      this.socket = null
    }
  }
}

// 导出单例
export const webSocketService = new WebSocketService()

services/webrtc.service.ts

import { webSocketService, WebSocketService } from './websocket.service'

interface RTCConfig {
  iceServers: RTCIceServer[]
}

export class WebRTCService {
  private peerConnection: RTCPeerConnection | null = null
  private localStream: MediaStream | null = null
  private remoteStream: MediaStream | null = null
  private dataChannel: RTCDataChannel | null = null
  private readonly config: RTCConfig = {
    iceServers: [
      { urls: 'stun:stun.l.google.com:19302' },
      // 生产环境需配置 TURN 服务器
      // { urls: 'turn:your-turn-server.com:3478', username: 'xxx', credential: 'xxx' }
    ],
  }

  constructor(private signalingService: WebSocketService = webSocketService) {
    this.setupSignalingHandlers()
  }

  private setupSignalingHandlers(): void {
    const eventBus = this.signalingService.getEventBus()
    eventBus.on('webrtc_signal_received', this.handleRemoteSignal.bind(this))
  }

  // 作为接收方,发起连接请求
  async startConnection(streamId: string): Promise<void> {
    if (this.peerConnection) {
      console.warn('已有存在的WebRTC连接,先关闭')
      this.closeConnection()
    }

    try {
      this.peerConnection = new RTCPeerConnection(this.config)

      // 设置 ICE 候选处理
      this.peerConnection.onicecandidate = (event) => {
        if (event.candidate) {
          this.signalingService.send({
            type: 'webrtc_signal',
            target: streamId,
            payload: { iceCandidate: event.candidate },
          })
        }
      }

      // 接收远端流
      this.peerConnection.ontrack = (event) => {
        console.log('收到远端视频流轨道')
        if (event.streams && event.streams[0]) {
          this.remoteStream = event.streams[0]
          // 触发自定义事件,让组件可以获取流
          const streamEvent = new CustomEvent('webrtc-stream-received', {
            detail: { stream: this.remoteStream },
          })
          window.dispatchEvent(streamEvent)
        }
      }

      // 创建数据通道(可选,用于传输控制指令等)
      this.dataChannel = this.peerConnection.createDataChannel('controlChannel')
      this.setupDataChannel()

      // 创建 Offer
      const offer = await this.peerConnection.createOffer()
      await this.peerConnection.setLocalDescription(offer)

      // 发送 Offer 到信令服务器
      this.signalingService.send({
        type: 'webrtc_signal',
        target: streamId,
        payload: { sdp: this.peerConnection.localDescription },
      })

      console.log('WebRTC连接已发起,等待远端应答...')
    } catch (error) {
      console.error('创建WebRTC连接失败:', error)
      throw error
    }
  }

  // 处理远端信令(SDP Answer 或 ICE Candidate)
  async handleRemoteSignal(signal: any): Promise<void> {
    if (!this.peerConnection) {
      console.warn('收到信令时,PeerConnection 未初始化')
      return
    }

    try {
      if (signal.sdp) {
        const remoteDesc = new RTCSessionDescription(signal.sdp)
        await this.peerConnection.setRemoteDescription(remoteDesc)
        console.log('已设置远端SDP描述')

        // 如果收到的是Offer,需要创建Answer(本例中我们是接收方,通常只处理Answer)
        if (signal.sdp.type === 'offer') {
          const answer = await this.peerConnection.createAnswer()
          await this.peerConnection.setLocalDescription(answer)
          this.signalingService.send({
            type: 'webrtc_signal',
            target: 'sender', // 应替换为实际发送方ID
            payload: { sdp: this.peerConnection.localDescription },
          })
        }
      } else if (signal.iceCandidate) {
        await this.peerConnection.addIceCandidate(new RTCIceCandidate(signal.iceCandidate))
        console.log('已添加ICE候选')
      }
    } catch (error) {
      console.error('处理远端信令失败:', error)
    }
  }

  private setupDataChannel(): void {
    if (!this.dataChannel) return

    this.dataChannel.onopen = () => {
      console.log('RTCDataChannel 已打开')
      // 可以发送控制指令
      this.dataChannel?.send(JSON.stringify({ type: 'handshake', message: '通道就绪' }))
    }

    this.dataChannel.onmessage = (event) => {
      console.log('收到DataChannel消息:', event.data)
      // 处理来自远端的控制指令或数据
      try {
        const data = JSON.parse(event.data)
        // 分发处理...
      } catch (e) {
        console.log('收到非JSON消息:', event.data)
      }
    }

    this.dataChannel.onerror = (error) => {
      console.error('DataChannel错误:', error)
    }
  }

  sendDataViaChannel(data: object): void {
    if (this.dataChannel?.readyState === 'open') {
      this.dataChannel.send(JSON.stringify(data))
    } else {
      console.warn('DataChannel未就绪,消息发送失败')
    }
  }

  closeConnection(): void {
    if (this.dataChannel) {
      this.dataChannel.close()
      this.dataChannel = null
    }
    if (this.peerConnection) {
      this.peerConnection.close()
      this.peerConnection = null
    }
    if (this.localStream) {
      this.localStream.getTracks().forEach((track) => track.stop())
      this.localStream = null
    }
    this.remoteStream = null
    console.log('WebRTC连接已关闭')
  }

  getRemoteStream(): MediaStream | null {
    return this.remoteStream
  }
}

// 导出单例
export const webRTCService = new WebRTCService()

3. 状态管理 (stores/dashboard.store.ts)

import { create } from 'zustand'
import { subscribeWithSelector } from 'zustand/middleware'

interface DashboardState {
  // 业务数据
  orderCount: number
  onlineUserCount: number
  lastUpdateTime: number | null
  // 系统状态
  isWebSocketConnected: boolean
  isVideoStreamActive: boolean
  currentLayout: 'grid' | 'focus' | 'custom'
  // 筛选条件
  timeRange: 'realtime' | 'hourly' | 'daily'
  selectedRegion: string | null
}

interface DashboardActions {
  updateBusinessData: (orders: number, onlineUsers: number) => void
  setWebSocketStatus: (connected: boolean) => void
  setVideoStreamStatus: (active: boolean) => void
  switchLayout: (layout: DashboardState['currentLayout']) => void
  setTimeRange: (range: DashboardState['timeRange']) => void
  setSelectedRegion: (region: string | null) => void
  reset: () => void
}

const initialState: DashboardState = {
  orderCount: 0,
  onlineUserCount: 0,
  lastUpdateTime: null,
  isWebSocketConnected: false,
  isVideoStreamActive: false,
  currentLayout: 'grid',
  timeRange: 'realtime',
  selectedRegion: null,
}

export const useDashboardStore = create<DashboardState & DashboardActions>()(
  subscribeWithSelector((set) => ({
    ...initialState,

    updateBusinessData: (orders, onlineUsers) =>
      set({
        orderCount: orders,
        onlineUserCount: onlineUsers,
        lastUpdateTime: Date.now(),
      }),

    setWebSocketStatus: (connected) => set({ isWebSocketConnected: connected }),

    setVideoStreamStatus: (active) => set({ isVideoStreamActive: active }),

    switchLayout: (layout) => set({ currentLayout: layout }),

    setTimeRange: (range) => set({ timeRange: range }),

    setSelectedRegion: (region) => set({ selectedRegion: region }),

    reset: () => set(initialState),
  }))
)

// 可选:订阅状态变化,用于持久化或日志
useDashboardStore.subscribe(
  (state) => [state.orderCount, state.onlineUserCount],
  ([orders, users]) => {
    console.log(`业务数据更新 - 订单: ${orders}, 在线用户: ${users}`)
  }
)

4. 可视化组件实现

components/VideoStream.vue

<template>
  <div class="video-stream-container">
    <div class="video-header">
      <h3>{{ title }}</h3>
      <div class="status-indicator" :class="{ active: isStreamActive }"></div>
    </div>
    <div class="video-wrapper">
      <video
        ref="videoElement"
        autoplay
        playsinline
        muted
        class="video-element"
        :class="{ 'has-stream': isStreamActive }"
      ></video>
      <div v-if="!isStreamActive" class="video-placeholder">
        <div class="placeholder-icon">📹</div>
        <p>等待视频流连接...</p>
        <button v-if="showConnectButton" @click="emit('connect')" class="connect-btn">
          连接视频流
        </button>
      </div>
    </div>
    <div v-if="showStats" class="video-stats">
      <span>分辨率: {{ videoStats.resolution }}</span>
      <span>帧率: {{ videoStats.frameRate }} fps</span>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { webRTCService } from '@/services/webrtc.service'

interface Props {
  title?: string
  streamId?: string
  showConnectButton?: boolean
  showStats?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  title: '实时视频流',
  streamId: 'default-stream',
  showConnectButton: true,
  showStats: true,
})

const emit = defineEmits<{
  connect: []
  streamActive: [isActive: boolean]
}>()

const videoElement = ref<HTMLVideoElement | null>(null)
const isStreamActive = ref(false)
const videoStats = ref({
  resolution: 'N/A',
  frameRate: 0,
})

let statsInterval: number | null = null

const handleStreamReceived = (event: Event) => {
  const customEvent = event as CustomEvent<{ stream: MediaStream }>
  if (videoElement.value && customEvent.detail?.stream) {
    videoElement.value.srcObject = customEvent.detail.stream
    isStreamActive.value = true
    emit('streamActive', true)
    startStatsMonitoring(customEvent.detail.stream)
  }
}

const startStatsMonitoring = (stream: MediaStream) => {
  if (statsInterval) clearInterval(statsInterval)

  statsInterval = window.setInterval(() => {
    if (videoElement.value && videoElement.value.videoWidth) {
      videoStats.value = {
        resolution: `${videoElement.value.videoWidth}x${videoElement.value.videoHeight}`,
        frameRate: Math.round(getFrameRate()),
      }
    }
  }, 1000)
}

const getFrameRate = (): number => {
  // 简化实现,实际应使用 VideoFrameCallback API 或计算时间差
  return 30 // 默认值
}

const connectToStream = async () => {
  try {
    await webRTCService.startConnection(props.streamId)
  } catch (error) {
    console.error('连接视频流失败:', error)
    alert('无法连接视频流,请检查网络和后端服务。')
  }
}

onMounted(() => {
  window.addEventListener('webrtc-stream-received', handleStreamReceived)
  // 组件挂载时自动连接(可选)
  // connectToStream()
})

onUnmounted(() => {
  window.removeEventListener('webrtc-stream-received', handleStreamReceived)
  if (statsInterval) clearInterval(statsInterval)
  webRTCService.closeConnection()
  isStreamActive.value = false
  emit('streamActive', false)
})

watch(
  () => props.streamId,
  (newId) => {
    if (newId && isStreamActive.value) {
      // 如果streamId变化且当前有活动流,重新连接
      webRTCService.closeConnection()
      connectToStream()
    }
  }
)
</script>

<style scoped>
.video-stream-container {
  background: var(--card-bg-color);
  border-radius: var(--border-radius-base);
  padding: var(--spacing-base);
  display: flex;
  flex-direction: column;
  height: 100%;
}

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

.video-header h3 {
  font-size: 1.1rem;
  font-weight: 600;
}

.status-indicator {
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background-color: #ff4d4f;
}

.status-indicator.active {
  background-color: #52c41a;
  box-shadow: 0 0 8px #52c41a;
}

.video-wrapper {
  position: relative;
  flex: 1;
  min-height: 0; /* 防止flex item溢出 */
  background-color: #000;
  border-radius: 4px;
  overflow: hidden;
}

.video-element {
  width: 100%;
  height: 100%;
  object-fit: contain;
  display: block;
}

.video-element.has-stream {
  background-color: transparent;
}

.video-placeholder {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  color: var(--text-color-secondary);
  background-color: rgba(0, 0, 0, 0.7);
}

.placeholder-icon {
  font-size: 48px;
  margin-bottom: 16px;
}

.connect-btn {
  margin-top: 16px;
  padding: 8px 16px;
  background-color: var(--primary-color);
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 0.9rem;
  transition: background-color 0.2s;
}

.connect-btn:hover {
  background-color: #1a7ad9;
}

.video-stats {
  margin-top: 12px;
  font-size: 0.8rem;
  color: var(--text-color-secondary);
  display: flex;
  justify-content: space-between;
}
</style>

components/DataDashboard.vue

<template>
  <div class="data-dashboard">
    <div class="dashboard-header">
      <h2>业务运营概览</h2>
      <div class="controls">
        <select v-model="selectedTimeRange" @change="handleTimeRangeChange">
          <option value="realtime">实时</option>
          <option value="hourly">小时</option>
          <option value="daily">日度</option>
        </select>
        <button @click="refreshData" class="refresh-btn" :disabled="isLoading">
          {{ isLoading ? '更新中...' : '刷新' }}
        </button>
      </div>
    </div>

    <div class="stats-cards">
      <div class="stat-card">
        <div class="stat-icon">📈</div>
        <div class="stat-content">
          <div class="stat-label">今日订单量</div>
          <div class="stat-value">{{ formatNumber(orderCount) }}</div>
          <div class="stat-trend" :class="orderTrendClass">
            {{ orderTrend }}%
          </div>
        </div>
      </div>
      <div class="stat-card">
        <div class="stat-icon">👥</div>
        <div class="stat-content">
          <div class="stat-label">当前在线人数</div>
          <div class="stat-value">{{ formatNumber(onlineUserCount) }}</div>
          <div class="stat-trend" :class="userTrendClass">
            {{ userTrend }}%
          </div>
        </div>
      </div>
      <div class="stat-card">
        <div class="stat-icon">💰</div>
        <div class="stat-content">
          <div class="stat-label">实时成交额</div>
          <div class="stat-value">¥{{ formatNumber(realtimeAmount) }}</div>
          <div class="stat-sub">每分钟更新</div>
        </div>
      </div>
    </div>

    <div class="charts-container">
      <div class="chart-wrapper">
        <div ref="ordersChartRef" class="chart"></div>
      </div>
      <div class="chart-wrapper">
        <div ref="usersChartRef" class="chart"></div>
      </div>
    </div>

    <div class="last-update">
      最后更新: {{ lastUpdateTime ? formatTime(lastUpdateTime) : '--' }}
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import * as echarts from 'echarts'
import { useDashboardStore } from '@/stores/dashboard.store'
import { webSocketService } from '@/services/websocket.service'

// 状态管理
const dashboardStore = useDashboardStore()
const orderCount = ref(0)
const onlineUserCount = ref(0)
const lastUpdateTime = ref<number | null>(null)
const realtimeAmount = ref(0)

// 图表相关
const ordersChartRef = ref<HTMLElement | null>(null)
const usersChartRef = ref<HTMLElement | null>(null)
let ordersChart: echarts.ECharts | null = null
let usersChart: echarts.ECharts | null = null

// UI状态
const selectedTimeRange = ref<'realtime' | 'hourly' | 'daily'>('realtime')
const isLoading = ref(false)
const orderTrend = ref(0)
const userTrend = ref(0)

const orderTrendClass = computed(() => (orderTrend.value >= 0 ? 'positive' : 'negative'))
const userTrendClass = computed(() => (userTrend.value >= 0 ? 'positive' : 'negative'))

// 模拟历史数据(实际应从后端获取)
const historicalOrders = ref<number[]>([120, 135, 118, 145, 160, 155, 170, 165, 180, 175])
const historicalUsers = ref<number[]>([850, 920, 880, 950, 1000, 980, 1050, 1020, 1100, 1080])

onMounted(() => {
  initCharts()
  setupWebSocketListener()
  // 模拟初始数据
  simulateDataUpdate()
})

onUnmounted(() => {
  if (ordersChart) ordersChart.dispose()
  if (usersChart) usersChart.dispose()
})

const initCharts = () => {
  nextTick(() => {
    if (ordersChartRef.value) {
      ordersChart = echarts.init(ordersChartRef.value)
      updateOrdersChart()
    }
    if (usersChartRef.value) {
      usersChart = echarts.init(usersChartRef.value)
      updateUsersChart()
    }

    // 响应窗口大小变化
    window.addEventListener('resize', handleResize)
  })
}

const handleResize = () => {
  ordersChart?.resize()
  usersChart?.resize()
}

const setupWebSocketListener = () => {
  const eventBus = webSocketService.getEventBus()
  eventBus.on('BUSINESS_DATA_UPDATED', handleBusinessDataUpdate)
}

const handleBusinessDataUpdate = (data: { orders: number; onlineUsers: number; timestamp: number }) => {
  orderCount.value = data.orders
  onlineUserCount.value = data.onlineUsers
  lastUpdateTime.value = data.timestamp

  // 更新趋势(简化计算)
  if (historicalOrders.value.length > 0) {
    const lastOrder = historicalOrders.value[historicalOrders.value.length - 1]
    orderTrend.value = ((data.orders - lastOrder) / lastOrder) * 100
  }
  if (historicalUsers.value.length > 0) {
    const lastUser = historicalUsers.value[historicalUsers.value.length - 1]
    userTrend.value = ((data.onlineUsers - lastUser) / lastUser) * 100
  }

  // 更新历史数据(模拟)
  historicalOrders.value.push(data.orders)
  historicalUsers.value.push(data.onlineUsers)
  if (historicalOrders.value.length > 20) {
    historicalOrders.value.shift()
    historicalUsers.value.shift()
  }

  // 更新图表
  updateOrdersChart()
  updateUsersChart()

  // 模拟实时成交额变化
  realtimeAmount.value = data.orders * 158 // 假设平均客单价
}

const updateOrdersChart = () => {
  if (!ordersChart) return

  const option: echarts.EChartsOption = {
    backgroundColor: 'transparent',
    tooltip: {
      trigger: 'axis',
      formatter: '{b}<br/>订单量: {c}',
    },
    grid: {
      left: '3%',
      right: '4%',
      bottom: '3%',
      top: '10%',
      containLabel: true,
    },
    xAxis: {
      type: 'category',
      data: historicalOrders.value.map((_, i) => `T-${historicalOrders.value.length - i}`),
      axisLine: { lineStyle: { color: '#666' } },
      axisLabel: { color: '#999' },
    },
    yAxis: {
      type: 'value',
      axisLine: { lineStyle: { color: '#666' } },
      axisLabel: { color: '#999' },
      splitLine: { lineStyle: { color: '#333', type: 'dashed' } },
    },
    series: [
      {
        name: '订单量',
        type: 'line',
        data: historicalOrders.value,
        smooth: true,
        lineStyle: { color: '#5470c6', width: 3 },
        itemStyle: { color: '#5470c6' },
        areaStyle: {
          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
            { offset: 0, color: 'rgba(84, 112, 198, 0.5)' },
            { offset: 1, color: 'rgba(84, 112, 198, 0.1)' },
          ]),
        },
      },
    ],
  }

  ordersChart.setOption(option)
}

const updateUsersChart = () => {
  if (!usersChart) return

  const option: echarts.EChartsOption = {
    backgroundColor: 'transparent',
    tooltip: {
      trigger: 'axis',
      formatter: '{b}<br/>在线人数: {c}',
    },
    grid: {
      left: '3%',
      right: '4%',
      bottom: '3%',
      top: '10%',
      containLabel: true,
    },
    xAxis: {
      type: 'category',
      data: historicalUsers.value.map((_, i) => `T-${historicalUsers.value.length - i}`),
      axisLine: { lineStyle: { color: '#666' } },
      axisLabel: { color: '#999' },
    },
    yAxis: {
      type: 'value',
      axisLine: { lineStyle: { color: '#666' } },
      axisLabel: { color: '#999' },
      splitLine: { lineStyle: { color: '#333', type: 'dashed' } },
    },
    series: [
      {
        name: '在线人数',
        type: 'bar',
        data: historicalUsers.value,
        itemStyle: {
          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
            { offset: 0, color: '#91cc75' },
            { offset: 1, color: '#fac858' },
          ]),
        },
      },
    ],
  }

  usersChart.setOption(option)
}

const handleTimeRangeChange = () => {
  isLoading.value = true
  // 模拟根据时间范围获取不同数据
  setTimeout(() => {
    // 这里实际应调用API获取对应时间范围的数据
    console.log(`切换时间范围到: ${selectedTimeRange.value}`)
    isLoading.value = false
  }, 500)
}

const refreshData = () => {
  isLoading.value = true
  // 模拟手动刷新
  setTimeout(() => {
    simulateDataUpdate()
    isLoading.value = false
  }, 800)
}

const simulateDataUpdate = () => {
  // 模拟WebSocket数据更新
  const mockData = {
    orders: Math.floor(Math.random() * 200) + 100, // 100-300
    onlineUsers: Math.floor(Math.random() * 500) + 800, // 800-1300
    timestamp: Date.now(),
  }
  handleBusinessDataUpdate(mockData)
}

const formatNumber = (num: number): string => {
  return num.toLocaleString('zh-CN')
}

const formatTime = (timestamp: number): string => {
  const date = new Date(timestamp)
  return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}:${date.getSeconds().toString().padStart(2, '0')}`
}
</script>

<style scoped>
.data-dashboard {
  background: var(--card-bg-color);
  border-radius: var(--border-radius-base);
  padding: var(--spacing-base);
  height: 100%;
  display: flex;
  flex-direction: column;
}

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

.dashboard-header h2 {
  font-size: 1.3rem;
  font-weight: 600;
}

.controls {
  display: flex;
  gap: 12px;
  align-items: center;
}

.controls select {
  padding: 6px 12px;
  background-color: #2a3a52;
  color: var(--text-color-primary);
  border: 1px solid #3a4a62;
  border-radius: 4px;
  font-size: 0.9rem;
}

.refresh-btn {
  padding: 6px 16px;
  background-color: var(--primary-color);
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 0.9rem;
  transition: background-color 0.2s;
}

.refresh-btn:hover:not(:disabled) {
  background-color: #1a7ad9;
}

.refresh-btn:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.stats-cards {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 16px;
  margin-bottom: 24px;
}

.stat-card {
  background: linear-gradient(135deg, #1e2a3e 0%, #253044 100%);
  border-radius: 8px;
  padding: 20px;
  display: flex;
  align-items: center;
  border: 1px solid #2d3a4f;
}

.stat-icon {
  font-size: 2rem;
  margin-right: 16px;
}

.stat-content {
  flex: 1;
}

.stat-label {
  font-size: 0.9rem;
  color: var(--text-color-secondary);
  margin-bottom: 4px;
}

.stat-value {
  font-size: 1.8rem;
  font-weight: 700;
  margin-bottom: 4px;
}

.stat-trend {
  font-size: 0.85rem;
  font-weight: 600;
}

.stat-trend.positive {
  color: #52c41a;
}

.stat-trend.negative {
  color: #ff4d4f;
}

.stat-sub {
  font-size: 0.8rem;
  color: var(--text-color-secondary);
}

.charts-container {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 16px;
  flex: 1;
  min-height: 0;
}

.chart-wrapper {
  background: #1a2332;
  border-radius: 8px;
  padding: 12px;
}

.chart {
  width: 100%;
  height: 250px;
}

.last-update {
  margin-top: 16px;
  text-align: right;
  font-size: 0.85rem;
  color: var(--text-color-secondary);
}
</style>

components/LayoutContainer.vue

<template>
  <div class="layout-container" :class="`layout-${currentLayout}`">
    <header class="app-header">
      <div class="header-left">
        <h1 class="app-title">智慧运营指挥大屏</h1>
        <div class="connection-status">
          <span class="status-item" :class="{ connected: isWSConnected }">
            WebSocket: {{ isWSConnected ? '已连接' : '未连接' }}
          </span>
          <span class="status-item" :class="{ connected: isVideoActive }">
            视频流: {{ isVideoActive ? '活跃' : '未连接' }}
          </span>
        </div>
      </div>
      <div class="header-right">
        <div class="time-display">{{ currentTime }}</div>
        <button @click="toggleLayout" class="layout-toggle">
          切换布局 ({{ layoutNames[currentLayout] }})
        </button>
      </div>
    </header>

    <main class="main-content">
      <div class="content-left">
        <VideoStream
          title="实时监控画面"
          stream-id="监控摄像头-01"
          :show-connect-button="true"
          @stream-active="handleVideoActive"
          class="video-section"
        />
        <div class="control-panel">
          <h3>控制面板</h3>
          <div class="control-buttons">
            <button @click="sendControlCommand('snapshot')" class="control-btn">
              截图
            </button>
            <button @click="sendControlCommand('record_start')" class="control-btn">
              开始录制
            </button>
            <button @click="sendControlCommand('record_stop')" class="control-btn">
              停止录制
            </button>
          </div>
        </div>
      </div>

      <div class="content-center">
        <DataDashboard class="dashboard-section" />
      </div>

      <div class="content-right">
        <div class="alert-panel">
          <h3>实时告警</h3>
          <div class="alert-list">
            <div v-for="alert in alerts" :key="alert.id" class="alert-item" :class="`alert-${alert.level}`">
              <div class="alert-icon">{{ alertIcons[alert.level] }}</div>
              <div class="alert-content">
                <div class="alert-title">{{ alert.title }}</div>
                <div class="alert-time">{{ formatAlertTime(alert.timestamp) }}</div>
              </div>
            </div>
          </div>
        </div>
        <div class="info-panel">
          <h3>系统信息</h3>
          <div class="info-grid">
            <div class="info-item">
              <span class="info-label">CPU使用率</span>
              <span class="info-value">{{ systemInfo.cpu }}%</span>
            </div>
            <div class="info-item">
              <span class="info-label">内存使用</span>
              <span class="info-value">{{ systemInfo.memory }}%</span>
            </div>
            <div class="info-item">
              <span class="info-label">网络延迟</span>
              <span class="info-value">{{ systemInfo.latency }}ms</span>
            </div>
            <div class="info-item">
              <span class="info-label">数据更新</span>
              <span class="info-value">{{ systemInfo.updateRate }}/s</span>
            </div>
          </div>
        </div>
      </div>
    </main>

    <footer class="app-footer">
      <div class="footer-content">
        <span>© 2024 智慧运营平台</span>
        <span>版本: v1.0.0</span>
        <span>数据刷新间隔: 1秒</span>
        <span>最后心跳: {{ lastHeartbeat }}</span>
      </div>
    </footer>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useDashboardStore } from '@/stores/dashboard.store'
import { webSocketService } from '@/services/websocket.service'
import VideoStream from './VideoStream.vue'
import DataDashboard from './DataDashboard.vue'

const dashboardStore = useDashboardStore()

// 布局状态
const currentLayout = ref<'grid' | 'focus' | 'custom'>('grid')
const layoutNames = {
  grid: '网格',
  focus: '聚焦',
  custom: '自定义',
}

// 系统状态
const isWSConnected = ref(false)
const isVideoActive = ref(false)
const currentTime = ref('')
const lastHeartbeat = ref('--:--:--')
const systemInfo = ref({
  cpu: 24,
  memory: 68,
  latency: 45,
  updateRate: 10,
})

// 告警数据
const alerts = ref([
  { id: 1, title: '服务器CPU使用率超过80%', level: 'warning', timestamp: Date.now() - 300000 },
  { id: 2, title: '数据库连接数异常', level: 'error', timestamp: Date.now() - 120000 },
  { id: 3, title: '视频流连接中断', level: 'error', timestamp: Date.now() - 60000 },
  { id: 4, title: '订单量异常波动', level: 'info', timestamp: Date.now() - 30000 },
])

const alertIcons = {
  info: 'ℹ️',
  warning: '⚠️',
  error: '🚨',
}

onMounted(() => {
  // 初始化WebSocket连接
  initWebSocket()
  
  // 更新时间
  updateTime()
  const timeInterval = setInterval(updateTime, 1000)
  
  // 模拟系统信息更新
  const systemInterval = setInterval(updateSystemInfo, 5000)
  
  // 模拟心跳
  const heartbeatInterval = setInterval(updateHeartbeat, 10000)

  onUnmounted(() => {
    clearInterval(timeInterval)
    clearInterval(systemInterval)
    clearInterval(heartbeatInterval)
    webSocketService.disconnect()
  })
})

const initWebSocket = async () => {
  try {
    await webSocketService.connect()
    isWSConnected.value = true
    dashboardStore.setWebSocketStatus(true)
    
    const eventBus = webSocketService.getEventBus()
    eventBus.on('WS_CONNECTION_ESTABLISHED', () => {
      isWSConnected.value = true
      dashboardStore.setWebSocketStatus(true)
    })
    
    eventBus.on('connection_error', () => {
      isWSConnected.value = false
      dashboardStore.setWebSocketStatus(false)
    })
  } catch (error) {
    console.error('WebSocket连接失败:', error)
    isWSConnected.value = false
    dashboardStore.setWebSocketStatus(false)
  }
}

const handleVideoActive = (active: boolean) => {
  isVideoActive.value = active
  dashboardStore.setVideoStreamStatus(active)
}

const toggleLayout = () => {
  const layouts: Array<'grid' | 'focus' | 'custom'> = ['grid', 'focus', 'custom']
  const currentIndex = layouts.indexOf(currentLayout.value)
  const nextIndex = (currentIndex + 1) % layouts.length
  currentLayout.value = layouts[nextIndex]
  dashboardStore.switchLayout(currentLayout.value)
}

const sendControlCommand = (command: string) => {
  if (isWSConnected.value) {
    webSocketService.send({
      type: 'control_command',
      payload: { command, timestamp: Date.now() },
    })
    console.log(`发送控制命令: ${command}`)
  } else {
    alert('WebSocket未连接,无法发送控制命令')
  }
}

const updateTime = () => {
  const now = new Date()
  currentTime.value = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}`
}

const updateSystemInfo = () => {
  // 模拟系统信息变化
  systemInfo.value = {
    cpu: Math.floor(Math.random() * 30) + 20,
    memory: Math.floor(Math.random() * 30) + 60,
    latency: Math.floor(Math.random() * 30) + 30,
    updateRate: Math.floor(Math.random() * 5) + 8,
  }
}

const updateHeartbeat = () => {
  const now = new Date()
  lastHeartbeat.value = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}`
}

const formatAlertTime = (timestamp: number): string => {
  const diff = Date.now() - timestamp
  const minutes = Math.floor(diff / 60000)
  if (minutes < 1) return '刚刚'
  if (minutes < 60) return `${minutes}分钟前`
  const hours = Math.floor(minutes / 60)
  return `${hours}小时前`
}
</script>

<style scoped>
.layout-container {
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
  background-color: var(--bg-color);
  color: var(--text-color-primary);
}

.app-header {
  height: var(--header-height);
  background-color: #1a2332;
  padding: 0 24px;
  display: flex;
  justify-content: space-between;
  align-items: center;
  border-bottom: 1px solid #2d3a4f;
}

.header-left {
  display: flex;
  align-items: center;
  gap: 32px;
}

.app-title {
  font-size: 1.5rem;
  font-weight: 700;
  background: linear-gradient(90deg, #0052d9, #00a870);
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
}

.connection-status {
  display: flex;
  gap: 16px;
}

.status-item {
  padding: 4px 12px;
  background-color: #ff4d4f;
  border-radius: 12px;
  font-size: 0.85rem;
}

.status-item.connected {
  background-color: #52c41a;
}

.header-right {
  display: flex;
  align-items: center;
  gap: 20px;
}

.time-display {
  font-family: 'Courier New', monospace;
  font-size: 1.2rem;
  font-weight: 600;
  color: #00a870;
}

.layout-toggle {
  padding: 6px 16px;
  background-color: var(--primary-color);
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 0.9rem;
  transition: background-color 0.2s;
}

.layout-toggle:hover {
  background-color: #1a7ad9;
}

.main-content {
  flex: 1;
  display: grid;
  grid-template-columns: 1fr 2fr 1fr;
  gap: 16px;
  padding: 16px;
  overflow: hidden;
}

.content-left,
.content-center,
.content-right {
  display: flex;
  flex-direction: column;
  gap: 16px;
}

.video-section {
  flex: 2;
}

.dashboard-section {
  flex: 1;
}

.control-panel,
.alert-panel,
.info-panel {
  background: var(--card-bg-color);
  border-radius: var(--border-radius-base);
  padding: 16px;
}

.control-panel h3,
.alert-panel h3,
.info-panel h3 {
  margin-bottom: 12px;
  font-size: 1.1rem;
  font-weight: 600;
}

.control-buttons {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.control-btn {
  padding: 8px 12px;
  background-color: #2a3a52;
  color: var(--text-color-primary);
  border: 1px solid #3a4a62;
  border-radius: 4px;
  cursor: pointer;
  text-align: left;
  transition: all 0.2s;
}

.control-btn:hover {
  background-color: #3a4a62;
  border-color: #4a5a72;
}

.alert-list {
  display: flex;
  flex-direction: column;
  gap: 8px;
  max-height: 200px;
  overflow-y: auto;
}

.alert-item {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 8px;
  border-radius: 4px;
  background-color: rgba(255, 255, 255, 0.05);
}

.alert-item.alert-info {
  border-left: 3px solid #1890ff;
}

.alert-item.alert-warning {
  border-left: 3px solid #faad14;
}

.alert-item.alert-error {
  border-left: 3px solid #ff4d4f;
}

.alert-icon {
  font-size: 1.2rem;
}

.alert-content {
  flex: 1;
}

.alert-title {
  font-size: 0.9rem;
  margin-bottom: 2px;
}

.alert-time {
  font-size: 0.8rem;
  color: var(--text-color-secondary);
}

.info-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 12px;
}

.info-item {
  display: flex;
  justify-content: space-between;
  padding: 8px;
  background-color: rgba(255, 255, 255, 0.05);
  border-radius: 4px;
}

.info-label {
  font-size: 0.9rem;
  color: var(--text-color-secondary);
}

.info-value {
  font-size: 0.9rem;
  font-weight: 600;
}

.app-footer {
  height: 40px;
  background-color: #1a2332;
  border-top: 1px solid #2d3a4f;
  display: flex;
  align-items: center;
  justify-content: center;
}

.footer-content {
  display: flex;
  gap: 32px;
  font-size: 0.85rem;
  color: var(--text-color-secondary);
}

/* 布局变体 */
.layout-container.layout-focus .main-content {
  grid-template-columns: 0.5fr 3fr 0.5fr;
}

.layout-container.layout-custom .main-content {
  grid-template-columns: 1fr 1fr 1fr;
}
</style>

5. 应用入口与主组件

App.vue

<template>
  <LayoutContainer />
</template>

<script setup lang="ts">
import LayoutContainer from './components/LayoutContainer.vue'
</script>

<style>
#app {
  width: 100vw;
  height: 100vh;
  overflow: hidden;
}
</style>

main.ts

import { createApp } from 'vue'
import App from './App.vue'
import './styles/global.css'

// 注册ECharts插件
import { initECharts } from './plugins/echarts.plugin'
initECharts()

const app = createApp(App)
app.mount('#app')

plugins/echarts.plugin.ts

import * as echarts from 'echarts'

export function initECharts() {
  // 注册主题(可选)
  echarts.registerTheme('dashboard-dark', {
    backgroundColor: 'transparent',
    textStyle: {
      color: '#e5e6eb',
    },
    title: {
      textStyle: {
        color: '#e5e6eb',
      },
    },
    line: {
      itemStyle: {
        borderWidth: 2,
      },
      lineStyle: {
        width: 3,
      },
      symbolSize: 8,
      symbol: 'circle',
    },
  })

  // 全局配置
  echarts.setOptions({
    useUTC: false,
    animationDuration: 300,
    animationEasing: 'cubicOut',
  })

  console.log('ECharts插件已初始化,主题已注册')
}

6. 后端WebSocket服务器示例 (server.py)

#!/usr/bin/env python3
"""
简易WebSocket服务器,模拟业务数据推送和WebRTC信令转发
运行: python server.py
前端连接: ws://localhost:8765
"""
import asyncio
import json
import random
import websockets
from datetime import datetime
from typing import Set

# 存储所有连接的客户端
connected_clients: Set[websockets.WebSocketServerProtocol] = set()

async def broadcast_business_data():
    """每秒广播一次模拟的业务数据"""
    while True:
        if connected_clients:
            data = {
                "type": "business_data",
                "payload": {
                    "orders": random.randint(100, 300),
                    "onlineUsers": random.randint(800, 1300),
                    "timestamp": int(datetime.now().timestamp() * 1000)
                }
            }
            message = json.dumps(data)
            # 广播给所有客户端
            await asyncio.gather(
                *[client.send(message) for client in connected_clients],
                return_exceptions=True
            )
        await asyncio.sleep(1)  # 每秒更新一次

async def handle_client(websocket):
    """处理单个客户端连接"""
    # 注册新客户端
    connected_clients.add(websocket)
    client_id = id(websocket)
    print(f"客户端 {client_id} 已连接,当前连接数: {len(connected_clients)}")
    
    try:
        # 发送欢迎消息
        welcome_msg = {
            "type": "control_command",
            "payload": {
                "command": "welcome",
                "message": f"已连接到服务器,您的ID: {client_id}",
                "timestamp": int(datetime.now().timestamp() * 1000)
            }
        }
        await websocket.send(json.dumps(welcome_msg))
        
        # 处理来自客户端的消息
        async for message in websocket:
            try:
                data = json.loads(message)
                print(f"收到来自客户端 {client_id} 的消息: {data['type']}")
                
                # 根据消息类型处理
                if data["type"] == "webrtc_signal":
                    # WebRTC信令消息,广播给所有其他客户端(简单示例)
                    # 实际应用中应根据target字段定向转发
                    data["sender"] = client_id
                    broadcast_msg = json.dumps(data)
                    tasks = []
                    for client in connected_clients:
                        if client != websocket:
                            tasks.append(client.send(broadcast_msg))
                    if tasks:
                        await asyncio.gather(*tasks, return_exceptions=True)
                        
                elif data["type"] == "control_command":
                    # 控制命令,记录日志
                    print(f"控制命令: {data['payload']}")
                    # 可以在这里处理特定命令并广播响应
                    
            except json.JSONDecodeError:
                print(f"客户端 {client_id} 发送了无效的JSON消息")
            except KeyError:
                print(f"客户端 {client_id} 发送的消息格式错误")
                
    except websockets.exceptions.ConnectionClosed:
        print(f"客户端 {client_id} 连接已关闭")
    finally:
        # 移除断开连接的客户端
        connected_clients.remove(websocket)
        print(f"客户端 {client_id} 已断开,当前连接数: {len(connected_clients)}")

async def main():
    """启动WebSocket服务器"""
    # 启动业务数据广播任务
    broadcast_task = asyncio.create_task(broadcast_business_data())
    
    # 启动WebSocket服务器
    server = await websockets.serve(
        handle_client,
        "0.0.0.0",  # 监听所有接口
        8765,        # 端口
        ping_interval=20,   # 每20秒发送一次ping
        ping_timeout=40      # 40秒无响应则断开
    )
    
    print("WebSocket服务器已启动,监听 ws://0.0.0.0:8765")
    print("按 Ctrl+C 停止服务器")
    
    try:
        await server.wait_closed()
    except KeyboardInterrupt:
        print("\n正在关闭服务器...")
    finally:
        # 取消广播任务
        broadcast_task.cancel()
        try:
            await broadcast_task
        except asyncio.CancelledError:
            pass
        
        # 关闭所有客户端连接
        if connected_clients:
            print(f"正在关闭 {len(connected_clients)} 个客户端连接...")
            await asyncio.gather(
                *[client.close() for client in connected_clients],
                return_exceptions=True
            )

if __name__ == "__main__":
    asyncio.run(main())

7. 运行说明

前端运行步骤:

  1. 安装依赖

    npm install
    # 或使用 yarn/pnpm
  2. 启动开发服务器

    npm run dev

    应用将在 http://localhost:5173 启动。

后端运行步骤:

  1. 安装 Python 依赖

    pip install websockets
  2. 启动 WebSocket 服务器

    python server.py

    服务器将在 ws://localhost:8765 监听。

功能验证:

  1. 打开浏览器访问 http://localhost:5173

  2. 观察顶部连接状态指示器,WebSocket 应显示 "已连接"

  3. 点击视频组件中的 "连接视频流" 按钮(注意:需要真实的 WebRTC 信令服务器和视频源,此处仅为前端演示)

  4. 观察数据仪表盘,订单量和在线人数应每秒自动更新

  5. 尝试切换时间范围筛选器

  6. 点击控制面板中的按钮,查看浏览器控制台输出的控制命令

  7. 观察实时告警和系统信息面板的更新

关键配置说明:

  1. WebRTC 配置:如需真实视频流,需配置有效的 TURN 服务器并实现完整的信令交换逻辑

  2. 主题定制:在 styles/global.css 中修改 CSS 变量可调整整体视觉风格

  3. 数据源适配:修改 server.py 中的 broadcast_business_data 函数可接入真实业务数据

  4. 布局响应:组件已内置响应式设计,可适配不同分辨率的大屏

此完整示例展示了如何将 WebRTC 低延迟视频流、WebSocket 实时数据通信与现代化大屏可视化组件相结合,构建一个功能完整、结构清晰且易于扩展的业务运营监控系统。所有代码均可直接运行,并提供了详细注释说明各模块功能。


大屏可视化系统:WebRTC视频流与WebSocket实时数据集成方案
https://uniomo.com/archives/da-ping-ke-shi-hua-xi-tong-webrtcshi-pin-liu-yu-websocketshi-shi-shu-ju-ji-cheng-fang-an
作者
雨落秋垣
发布于
2026年01月07日
许可协议