大屏可视化系统:WebRTC视频流与WebSocket实时数据集成方案
一、项目初始化与依赖配置
构建一个集成了 WebRTC 低延迟视频流与 WebSocket 实时业务数据的大屏可视化应用,首要任务是搭建一个清晰、可扩展且功能完整的开发环境。本节将基于当前(2024-2026 年)的技术实践,明确项目所需的核心技术栈、关键依赖库,并提供初始化的配置指引。
1. 技术栈选型与架构定位
在项目启动阶段,明确技术选型是奠定可扩展架构的基础。根据行业最佳实践,一个现代的大屏可视化项目通常采用分层、解耦的架构思想。
前端框架与语言:推荐使用 Vue 3、React 或 Angular 等现代前端框架,结合 TypeScript 以获得更好的类型安全和开发体验。TypeScript 的强类型特性在管理复杂的实时数据流和组件通信时尤为重要。
可视化渲染库:根据渲染需求选择:
Canvas 引擎:对于需要高频更新、大规模数据点渲染(如万级数据点的动态图表)的场景,ECharts、VChart 是高性能的选择。Canvas 采用即时模式渲染,性能优于 SVG。
SVG 引擎:对于需要复杂交互、事件绑定和无损缩放的场景(如可下钻的地图),D3.js 提供了极高的灵活性。一些库如 ECharts 也支持 SVG 渲染器。
3D 可视化:如需三维场景展示(如数字孪生工厂),Three.js 是标准选择。
最佳实践:成熟的架构应支持双引擎或多引擎,例如在 GoView 等项目中同时集成 ECharts(复杂统计)和 VChart(轻量实时),根据场景智能选择。
状态管理:对于管理跨组件的复杂共享状态(如全局筛选条件、用户信息、实时数据快照),推荐采用现代轻量级状态库。
Zustand:以其极简的 API(创建 Store 仅需数行代码)、约 1.2KB 的超小体积以及出色的性能(支持细粒度状态订阅)成为当前新项目的热门默认选择,能大幅提升开发效率。
Redux:在超大型、已有深厚积累或对时间旅行调试有强依赖的项目中仍可考虑,但其样板代码较多,包体积约 12KB。
组件通信:对于跨层级、一次性的通知(如窗口缩放完成),可采用轻量级的事件总线(如
mitt)作为状态管理的补充,实现组件间松耦合通信。
构建工具:Vite 或 Webpack。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 与实时通信依赖
前端:使用浏览器原生
WebSocketAPI 或更封装的库(如socket.io-client)建立与信令服务器的连接。后端(信令服务器):以 Python 为例,使用
websockets库。# Python 依赖 pip install websockets该服务器负责:
转发 WebRTC 的 SDP Offer/Answer 和 ICE 候选信息。
广播或定向发送实时业务数据(如订单量、在线人数)。
(3) 可视化与UI依赖
图表库:安装选定的可视化库。
# 例如,使用ECharts npm install echarts # 或使用VChart npm install @visactor/vchartUI 组件库:根据所选前端框架选择,如基于 Vue3 的
Naive UI、Element Plus,或基于 React 的Ant Design。这些组件库能加速构建大屏的控制面板、布局容器等。
(4) 工程化与架构支撑依赖
状态管理:根据选型安装。
# Zustand (React) npm install zustand # Pinia (Vue 3) npm install pinia插件化 / 模块化支持:如果采用微前端或插件化架构,需要配置构建工具的模块联邦能力。
类型定义:为使用的库安装 TypeScript 类型定义文件(如
@types/websocket)。
3. 项目初始化与环境搭建步骤
创建项目脚手架:使用框架官方 CLI 工具(如
create-vue、create-react-app)或基于 Vite 模板初始化项目。安装核心依赖:根据上述选型,通过包管理器(npm/yarn/pnpm)一次性安装所有确定的依赖。
配置构建工具:在
vite.config.ts或webpack.config.js中,配置别名(alias)、代理(proxy)以方便开发,并为生产环境优化(代码分割、压缩)。设置目录结构:采用模块化设计,创建清晰的目录,例如:
src/ ├── assets/ # 静态资源 ├── components/ # 通用组件 │ ├── charts/ # 图表组件(封装ECharts等) │ ├── layout/ # 布局组件 │ └── ... ├── composables/ # Vue组合式函数 (或 React hooks) ├── stores/ # 状态管理 (Pinia/Zustand stores) ├── plugins/ # 插件或可动态加载的模块 ├── views/ # 页面视图 ├── utils/ # 工具函数 ├── types/ # TypeScript类型定义 └── main.ts # 应用入口配置主题与样式系统:建立基于 CSS 变量或 Sass/Less 的全局主题系统,集中管理颜色、字体、间距等设计令牌(Design Tokens),确保所有可视化组件风格一致。
初始化通信模块:创建
websocket.service.ts和webrtc.service.ts等文件,封装 WebSocket 连接管理、消息分发和 WebRTC PeerConnection 的创建、信令交换等通用逻辑,实现与业务组件的解耦。
通过以上步骤,一个兼顾功能完整性、代码清晰度和未来可扩展性的大屏可视化项目基础环境便搭建完成,为后续集成低延迟视频流与实时数据打下了坚实的技术地基。
二、可扩展架构设计
面向 2024-2026 年的大屏可视化项目,其架构设计的核心目标是构建一个能够从容应对数据量增长、业务需求频繁变化以及多场景灵活部署的系统。基于分层解耦与配置驱动的思想,本项目的可扩展架构旨在将WebRTC 低延迟视频流、WebSocket 实时业务数据、多引擎可视化渲染、统一状态管理以及插件化动态扩展等核心能力有机整合,形成一个高内聚、低耦合、易于维护和扩展的技术体系。
一、 分层解耦与配置驱动架构
现代大屏系统正从“硬编码”向“配置化”演进。本架构采用清晰的分层设计,将系统解耦为可视化层、布局层、数据层、主题层和工具层,每一层均可独立演进。
配置驱动的布局与渲染:布局层采用基于JSON Schema 的配置来描述大屏的网格结构、响应式断点规则和组件位置。这使得非技术人员可通过修改配置文件(而非代码)来调整大屏的整体排版与组件排布,实现了极高的灵活性。可视化层支持ECharts 与 VChart 双引擎,可根据场景智能选择或指定:ECharts 适用于组件丰富的复杂统计图表,而 VChart 在轻量化和大屏实时数据流渲染方面表现更优。渲染引擎的选择策略(如根据数据量阈值自动切换)本身也可作为配置项。
统一数据平台与前端解耦:为解决多源(WebSocket 业务数据、WebRTC 视频流、API)数据“衔接断层”的问题,架构中引入了一个逻辑上的统一数据适配层。该层负责对接所有原始数据源,通过预定义的数据适配器(Adapter) 进行清洗、格式转换与融合,形成前端可视化组件可直接消费的统一数据格式。同时,通过拦截器(Interceptor) 为所有数据请求添加统一的认证、错误处理与日志逻辑。
二、 插件化动态扩展机制
插件化是支撑业务灵活性和技术栈解耦的核心。本架构参考微前端与模块联邦思想,实现前端功能的“热插拔”。
插件定义与封装:每个功能模块(如一个特殊图表、一个 3D 模型组件、一个数据源处理器)均可封装为独立插件。插件是一个独立的模块包,包含其完整的视图、逻辑与样式,并对外暴露标准的元数据接口(如
pluginCode、version、entryUrl)。动态加载与渲染:主程序作为轻量级容器,维护一个插件注册中心。当需要加载某个插件时,根据其
entryUrl,利用 Webpack Module Federation 或 Vite 的动态导入能力远程加载模块代码。加载成功后,利用框架的动态组件能力(如 Vue 的defineAsyncComponent)进行实例化与渲染。开放的数据与事件协议:为确保插件与主程序及其他插件协同工作,定义了开放的通信协议。
数据协议:主程序通过 Props 或 Context 向插件注入统一处理后的数据。插件也可按协议主动请求数据。
事件交互协议:建立轻量级事件总线(Event Bus),用于处理跨插件、非父子关系的解耦通信。例如,一个 3D 场景插件可以抛出
modelClicked事件,携带设备 ID,而一个图表插件监听此事件并更新为对应设备的数据。这避免了组件间的直接依赖,实现了松耦合联动。
三、 状态管理与组件通信设计
复杂的大屏状态需要可预测、可调试的管理方案。综合当前最佳实践,本架构优先采用Zustand作为核心状态管理库。
选型依据:Zustand 以其极简的 API(创建 Store 仅需数行代码)、出色的性能(约 1.2KB 体积,支持细粒度状态订阅)和平缓的学习曲线,成为大多数大屏项目的优选。它避免了 Redux 的冗长样板代码,能更高效地管理全局主题、用户筛选条件、实时数据快照等共享状态。
混合通信模式:采用 “状态管理为主,事件总线为辅” 的混合模式。
复杂共享状态:如全局筛选条件、用户权限、实时数据看板的核心指标,由 Zustand Store 集中管理,保证单一数据源和可预测的更新。
一次性、解耦的通知:如窗口缩放完成、某个动画播放完毕、跨层级组件的简单消息传递,则通过事件总线进行发布 / 订阅。这既保持了相关组件的独立性,又满足了通信需求。
状态结构设计:状态按业务领域(如“视频监控”、“业务概览”、“实时预警”)而非技术类型进行组织,提升可维护性。为派生数据使用记忆化选择器(Memoized Selectors) 优化性能。
四、 数据流与渲染性能优化架构
可扩展性必须建立在稳定的性能基础之上。架构在数据流与渲染层面内置了优化策略。
智能渲染引擎选择:根据数据规模与交互需求,在Canvas与SVG间做出智能决策或混合使用。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)的超低延迟流传输。其
RTCPeerConnectionAPI 是建立连接并接收 / 发送媒体流的基石。
这种分工确保了信令的可靠性,同时让媒体流享有最低的网络传输延迟。
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 还为业务数据同步提供了强大扩展能力。
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 })); };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())
🛡️ 实时数据治理与性能保障
为应对高频率数据推送可能带来的前端性能与体验问题,统一数据适配层内置了关键的数据治理策略:
🔗 与状态管理及事件总线的集成
经过适配层处理后的纯净业务数据,被注入到对应的 Zustand Store 中。例如,实时运营指标会更新 useRealtimeDashboardStore。同时,适配层或服务层会通过轻量级事件总线(mitt)发布相应的事件,例如 BUSINESS_DATA_UPDATED。这使得:
可视化组件:通过订阅 Store 或监听事件,实现数据的响应式渲染。
插件化模块:可以通过事件总线订阅
BUSINESS_DATA_UPDATED事件,在无需修改核心代码的情况下,对数据做出自定义响应或渲染。控制指令:通过 WebSocket 接收的
control_command类型消息,经适配层转换后,可直接调用相关函数或发布如LAYOUT_CHANGE_REQUESTED事件,由布局管理模块响应执行。
至此,实时业务数据通过 WebSocket 通道稳定接入,并经由统一数据适配层的治理,被安全、高效地分发至整个应用的状态管理与组件渲染体系,为最终的大屏可视化呈现提供了动态的数据血液。
五、大屏可视化组件实现
本章将基于前文构建的统一数据流与可扩展架构,具体阐述大屏可视化组件的实现模式。核心目标是构建一个配置驱动、高性能、可热插拔的组件生态系统,将接入的实时视频流与业务数据转化为直观、动态的视觉洞察。
一、配置驱动与声明式组件架构
现代大屏开发已从硬编码转向配置驱动开发(CDD),将界面布局、数据绑定与交互逻辑抽象为可配置的元数据,实现快速迭代与交付。
分层配置模型:组件实现严格遵循分层架构。
布局层:大屏的整体结构与组件位置由一份 JSON Schema 定义。该配置描述画布网格、响应式断点以及每个可视化单元(如图表、视频窗口、指标卡)的坐标、尺寸和层级关系。
组件层:每个可视化单元(如一个折线图)是一个独立的、可配置的模块。其所有可变属性(数据源 ID、图表类型、颜色、标题等)均通过 Props 或一个配置对象(
options)注入,实现 “容器与内容分离”。数据层:组件所需的数据源在配置中通过唯一标识(如
dataSourceId: “realtime_orders”)声明。组件内部不关心数据来自 WebSocket 还是 WebRTC,它只消费经由统一数据适配层处理后的、格式规范的数据流。主题层:视觉样式(色彩、字体、间距等)通过全局的 CSS 变量(Design Tokens) 或 Sass 变量管理。组件样式全部基于这些主题变量编写,支持一键切换亮 / 暗主题。
原子化与复用:基于 Vue 3/React 构建基础图表组件库。每个组件(如
<BaseChart />)是自包含的,封装自身的渲染、resize 和销毁逻辑。通过组合和配置这些原子组件,可以快速搭建复杂的业务大屏。
二、状态管理:Zustand为核心,事件总线为补充
为管理复杂的全局状态(如筛选条件、主题模式、用户权限)并实现高效组件通信,采用混合模式。
Zustand 作为中央状态库:对于需要跨多个组件共享且关系复杂的应用状态,使用 Zustand 创建 Store。其极简 API(约 1.2KB)和细粒度状态订阅能力,能精准控制组件重渲染,性能优异。例如,
useDashboardStore可以管理全局的筛选时间范围、高亮的数据维度等。事件总线处理解耦通信:对于一次性、跨层级、非父子关系的组件间通知(如图表点击触发地图下钻、视频播放完成通知),使用轻量级事件总线(如
mitt)。这实现了组件间的松耦合。例如,一个深层的 3D 模型插件可以抛出modelClicked事件,由顶层的控制面板监听并响应,而两者无需直接引用。数据流:WebSocket 推送的业务数据经适配层处理后,更新至 Zustand Store。图表组件通过 Selector 订阅 Store 中其关心的数据片段。当用户通过筛选器交互改变状态时,Store 更新,所有相关图表自动重绘。同时,可通过事件总线广播状态变更事件,供不直接依赖该状态但需响应的组件使用。
三、插件化架构与动态加载
为实现功能的“热插拔”与团队并行开发,采用基于模块联邦(Module Federation)或动态导入的插件化架构。
插件定义:每个可视化组件(如一个自定义的 3D 地球、一个特殊的甘特图)可打包为独立的插件模块。插件包需导出约定的接口,至少包含唯一
pluginCode、版本version和主入口组件。注册与加载:主程序维护一个插件注册中心(远程或本地配置)。当需要渲染某个组件时,根据其
pluginCode从注册中心获取插件模块的入口地址(entryUrl),然后通过动态import()或模块联邦的loadRemoteModule方法异步加载。渲染与通信:插件加载成功后,主程序将其渲染到画布指定位置,并通过 Props/Context 向其注入统一的数据、主题和事件总线实例。插件内部可以独立运行其逻辑,并通过事件总线与外界通信。
四、双引擎可视化支持与渲染策略
为平衡渲染性能与交互灵活性,支持 Canvas 与 SVG 双渲染引擎,并根据场景智能选择。
ECharts 与 VChart 双引擎:
ECharts:用于组件丰富、交互复杂的统计分析图表(如关系图、自定义系列)。
VChart:针对大屏实时数据刷新场景优化,在轻量化和高频更新方面表现更佳。
组件配置中可声明渲染引擎偏好,由主程序统一调度资源。
智能渲染策略:
Canvas 渲染:默认用于高频更新(如实时折线图)或数据量极大(万级节点)的场景,利用其即时模式渲染的优势保证性能。
SVG 渲染:用于需要复杂 DOM 交互(如精确点击、鼠标悬停提示)、无损缩放(如可下钻的地图)的组件。
系统可根据数据量阈值或组件类型配置,自动或手动指定渲染模式。
五、视频与数据叠加组件的帧级同步
对于需要将业务数据(如订单热区、在线人数标签)叠加到 WebRTC 视频流上的场景,实现精准同步至关重要。
SEI(补充增强信息)方案:利用 WebRTC 的 SEI 特性,将元数据(如 JSON 格式的物体坐标、指标数值)在编码端直接注入视频帧。接收端解码时,同步解析 SEI 数据,并调用 Canvas API 在
<video>元素上实时绘制叠加层(如框、线、文字)。这确保了数据与视频画面的帧级同步,实现“零延迟”叠加。Canvas 叠加绘制:在播放视频的 Canvas 或叠加的 Canvas 层上,使用
requestAnimationFrame进行循环绘制。绘制数据来源于:解析视频流中的 SEI 信息。
通过 RTCDataChannel 接收的、与视频流时间戳对齐的控制指令。
从 Zustand Store 中获取的、经过去重和节流处理的实时业务指标。
六、主题、响应式与性能优化
全局主题系统:所有组件样式基于一套CSS 自定义属性(变量) 定义。通过修改根元素的 CSS 变量,可实现整个大屏主题的一键切换。插件在开发时也必须遵循此主题变量体系。
响应式与自适应布局:
采用 CSS Flex/Grid 结合 JavaScript 等比缩放 的策略。布局配置(JSON Schema)中定义不同屏幕断点下的组件排列规则。
监听
resize事件,通过事件总线通知所有插件组件进行自适应调整。
组件级性能优化:
虚拟滚动与分片加载:对超长列表或海量点图,在组件内部实现虚拟滚动或数据分片渲染。
按需渲染:对非可视区域或折叠状态的组件,停止其数据订阅与动画渲染。
图表配置优化:关闭非必要的动画特效,对大数据集启用
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. 运行说明
前端运行步骤:
安装依赖:
npm install # 或使用 yarn/pnpm启动开发服务器:
npm run dev应用将在
http://localhost:5173启动。
后端运行步骤:
安装 Python 依赖:
pip install websockets启动 WebSocket 服务器:
python server.py服务器将在
ws://localhost:8765监听。
功能验证:
打开浏览器访问
http://localhost:5173观察顶部连接状态指示器,WebSocket 应显示 "已连接"
点击视频组件中的 "连接视频流" 按钮(注意:需要真实的 WebRTC 信令服务器和视频源,此处仅为前端演示)
观察数据仪表盘,订单量和在线人数应每秒自动更新
尝试切换时间范围筛选器
点击控制面板中的按钮,查看浏览器控制台输出的控制命令
观察实时告警和系统信息面板的更新
关键配置说明:
WebRTC 配置:如需真实视频流,需配置有效的 TURN 服务器并实现完整的信令交换逻辑
主题定制:在
styles/global.css中修改 CSS 变量可调整整体视觉风格数据源适配:修改
server.py中的broadcast_business_data函数可接入真实业务数据布局响应:组件已内置响应式设计,可适配不同分辨率的大屏
此完整示例展示了如何将 WebRTC 低延迟视频流、WebSocket 实时数据通信与现代化大屏可视化组件相结合,构建一个功能完整、结构清晰且易于扩展的业务运营监控系统。所有代码均可直接运行,并提供了详细注释说明各模块功能。