大屏可视化系统: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)建立与信令服务器的连接。
    1. 转发WebRTC的SDP Offer/Answer和ICE候选信息。
    2. 广播或定向发送实时业务数据(如订单量、在线人数)。

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

# Python 依赖 pip install websockets

该服务器负责:

(3) 可视化与UI依赖
  • UI组件库:根据所选前端框架选择,如基于Vue3的 Naive UIElement Plus,或基于React的 Ant Design。这些组件库能加速构建大屏的控制面板、布局容器等。

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

# 例如,使用ECharts npm install echarts # 或使用VChart npm install @visactor/vchart
(4) 工程化与架构支撑依赖
  • 插件化/模块化支持:如果采用微前端或插件化架构,需要配置构建工具的模块联邦能力。
  • 类型定义:为使用的库安装TypeScript类型定义文件(如 @types/websocket)。

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

# Zustand (React) npm install zustand # Pinia (Vue 3) npm install pinia

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

  1. 创建项目脚手架:使用框架官方CLI工具(如 create-vuecreate-react-app)或基于Vite模板初始化项目。
  2. 安装核心依赖:根据上述选型,通过包管理器(npm/yarn/pnpm)一次性安装所有确定的依赖。
  3. 配置构建工具:在 vite.config.tswebpack.config.js 中,配置别名(alias)、代理(proxy)以方便开发,并为生产环境优化(代码分割、压缩)。
  4. 配置主题与样式系统:建立基于CSS变量或Sass/Less的全局主题系统,集中管理颜色、字体、间距等设计令牌(Design Tokens),确保所有可视化组件风格一致。
  5. 初始化通信模块:创建 websocket.service.tswebrtc.service.ts 等文件,封装WebSocket连接管理、消息分发和WebRTC PeerConnection的创建、信令交换等通用逻辑,实现与业务组件的解耦。

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

src/ ├── assets/ # 静态资源 ├── components/ # 通用组件 │ ├── charts/ # 图表组件(封装ECharts等) │ ├── layout/ # 布局组件 │ └── ... ├── composables/ # Vue组合式函数 (或 React hooks) ├── stores/ # 状态管理 (Pinia/Zustand stores) ├── plugins/ # 插件或可动态加载的模块 ├── views/ # 页面视图 ├── utils/ # 工具函数 ├── types/ # TypeScript类型定义 └── main.ts # 应用入口

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

二、可扩展架构设计

面向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设备)。
  • 自适应码率与抗弱网:依赖RTCPeerConnection内置的拥塞控制(如GCC算法),并根据接收到的RTCP反馈(可通过peerConnection.getStats()获取)来驱动前端的降级策略(如提示网络状况)。

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

videoElement.srcObject = mediaStream; // 正确做法 // 避免使用已废弃的 URL.createObjectURL(stream)

3.3 数据同步与扩展应用

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

  1. SEI(补充增强信息)实现帧级同步: 对于需要将元数据(如物体识别框、传感器读数)与特定视频帧精准对齐的场景,SEI技术是终极方案。元数据被直接注入视频编码层的NAL单元,随帧传输和解码,实现“神同步”。
    • 注入端:在视频编码时,将JSON格式的元数据(如{objectId: 123, x: 100, y: 200})作为SEI信息插入。
    • 解析端:在大屏播放端,从解码后的视频帧中提取SEI数据,并驱动可视化组件(如Canvas叠加层)进行实时绘制。此部分通常需要额外的解码库或播放器支持(如WebCodecs API)。

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 })); };

通过上述步骤,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> <div> <h3>{{ title }}</h3> <div :class="{ active: isStreamActive }"></div> </div> <div> <video ref="videoElement" autoplay playsinline muted :class="{ 'has-stream': isStreamActive }" ></video> <div v-if="!isStreamActive"> <div>📹</div> <p>等待视频流连接...</p> <button v-if="showConnectButton" @click="emit('connect')"> 连接视频流 </button> </div> </div> <div v-if="showStats"> <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> <div> <h2>业务运营概览</h2> <div> <select v-model="selectedTimeRange" @change="handleTimeRangeChange"> <option value="realtime">实时</option> <option value="hourly">小时</option> <option value="daily">日度</option> </select> <button @click="refreshData" :disabled="isLoading"> {{ isLoading ? '更新中...' : '刷新' }} </button> </div> </div> <div> <div> <div>📈</div> <div> <div>今日订单量</div> <div>{{ formatNumber(orderCount) }}</div> <div :class="orderTrendClass"> {{ orderTrend }}% </div> </div> </div> <div> <div>👥</div> <div> <div>当前在线人数</div> <div>{{ formatNumber(onlineUserCount) }}</div> <div :class="userTrendClass"> {{ userTrend }}% </div> </div> </div> <div> <div>💰</div> <div> <div>实时成交额</div> <div>¥{{ formatNumber(realtimeAmount) }}</div> <div>每分钟更新</div> </div> </div> </div> <div> <div> <div ref="ordersChartRef"></div> </div> <div> <div ref="usersChartRef"></div> </div> </div> <div> 最后更新: {{ 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-${currentLayout}`"> <header> <div> <h1>智慧运营指挥大屏</h1> <div> <span :class="{ connected: isWSConnected }"> WebSocket: {{ isWSConnected ? '已连接' : '未连接' }} </span> <span :class="{ connected: isVideoActive }"> 视频流: {{ isVideoActive ? '活跃' : '未连接' }} </span> </div> </div> <div> <div>{{ currentTime }}</div> <button @click="toggleLayout"> 切换布局 ({{ layoutNames[currentLayout] }}) </button> </div> </header> <main> <div> <VideoStream title="实时监控画面" stream-id="监控摄像头-01" :show-connect-button="true" @stream-active="handleVideoActive" /> <div> <h3>控制面板</h3> <div> <button @click="sendControlCommand('snapshot')"> 截图 </button> <button @click="sendControlCommand('record_start')"> 开始录制 </button> <button @click="sendControlCommand('record_stop')"> 停止录制 </button> </div> </div> </div> <div> <DataDashboard /> </div> <div> <div> <h3>实时告警</h3> <div> <div v-for="alert in alerts" :key="alert.id" :class="`alert-${alert.level}`"> <div>{{ alertIcons[alert.level] }}</div> <div> <div>{{ alert.title }}</div> <div>{{ formatAlertTime(alert.timestamp) }}</div> </div> </div> </div> </div> <div> <h3>系统信息</h3> <div> <div> <span>CPU使用率</span> <span>{{ systemInfo.cpu }}%</span> </div> <div> <span>内存使用</span> <span>{{ systemInfo.memory }}%</span> </div> <div> <span>网络延迟</span> <span>{{ systemInfo.latency }}ms</span> </div> <div> <span>数据更新</span> <span>{{ systemInfo.updateRate }}/s</span> </div> </div> </div> </div> </main> <footer> <div> <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. 运行说明

前端运行步骤:

启动开发服务器

npm run dev

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

安装依赖

npm install # 或使用 yarn/pnpm
后端运行步骤:

启动WebSocket服务器

python server.py

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

安装Python依赖

pip install websockets
功能验证:
  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实时数据通信与现代化大屏可视化组件相结合,构建一个功能完整、结构清晰且易于扩展的业务运营监控系统。所有代码均可直接运行,并提供了详细注释说明各模块功能。

Read more

uniapp vue h5小程序奶茶点餐纯前端hbuilderx

uniapp vue h5小程序奶茶点餐纯前端hbuilderx

内容目录 * 一、详细介绍 * 二、效果展示 * 1.部分代码 * 2.效果图展示 * 三、学习资料下载 一、详细介绍 uniapp奶茶点餐纯前调试视频.mp4链接: uniapp奶茶点餐纯前调试视频注意事项: 本店所有代码都是我亲测100%跑过没有问题才上架 内含部署环境软件和详细调试教学视频 代码都是全的,请放心购买 虚拟物品具有复制性,不支持七天无理由退换 源码仅供学习参考, 商品内容纯属虚构可以提供定制,二次开发先导入hbuilderx 运行后会启动微信开发工具显示效果 二、效果展示 1.部分代码 代码如下(示例): 2.效果图展示 三、学习资料下载 蓝奏云:https://qumaw.lanzoul.com/iQ2KP3goqhjg

Clawdbot+Qwen3:32B从零开始:3步完成Web Chat平台本地部署(含截图)

Clawdbot+Qwen3:32B从零开始:3步完成Web Chat平台本地部署(含截图) 1. 为什么你需要这个本地Chat平台 你是不是也遇到过这些问题:想用大模型但担心数据上传到公有云?试过几个Web聊天界面,不是配置复杂就是响应慢?或者只是单纯想在自己电脑上跑一个真正属于自己的AI对话系统,不依赖网络、不看别人脸色? Clawdbot + Qwen3:32B 这个组合,就是为解决这些实际问题而生的。它不是又一个需要注册账号、绑定邮箱、等审核的SaaS服务,而是一个完全本地运行、数据不出设备、开箱即用的轻量级Web聊天平台。 这里没有复杂的Docker Compose编排,没有动辄半小时的环境搭建,也没有让人头大的证书配置。整个过程只需要三步:装好基础工具、拉起模型服务、启动前端界面。全程在终端敲几行命令,刷新浏览器就能开始对话。 更关键的是,它用的是通义千问最新发布的Qwen3:32B——目前开源领域综合能力最强的中文大模型之一。32B参数规模意味着更强的逻辑推理、更稳的长文本理解、更自然的多轮对话表现。而Clawdbot作为一款专注本地集成的轻量级代理网关,把模

资源高效+高精度识别|PaddleOCR-VL-WEB文档解析全场景适配

资源高效+高精度识别|PaddleOCR-VL-WEB文档解析全场景适配 写在前面 你有没有遇到过这样的情况:一份扫描版PDF里既有密密麻麻的正文、带公式的推导过程,又有跨页表格和手写批注,用传统OCR工具一识别,文字错位、表格散架、公式变乱码——最后还得人工逐字校对,半天时间白忙活? 这不是个别现象。在金融报告、科研论文、古籍档案、多语言合同等真实业务中,文档解析早已不是“把图片转成文字”这么简单。它需要同时理解布局结构、语义逻辑、视觉关系和多语言混排——而这些,正是PaddleOCR-VL-WEB真正发力的地方。 本文不讲抽象架构,不堆参数指标,只聚焦一件事:这个镜像到底能不能在你的日常工作中稳稳跑起来?识别准不准?部署难不难?支持哪些“难搞”的文档? 我用一台搭载RTX 4090D单卡的服务器,从零部署PaddleOCR-VL-WEB,实测了27份真实文档(含中文财报、英文技术手册、日文说明书、阿拉伯语合同、带手写体的实验记录本、含LaTeX公式的学术PDF),全程记录操作路径、关键配置、效果反馈和避坑要点。所有步骤均可复现,