Vue3 储能 EMS 前端实战(中):MQTT 实时数据与组态联动

系列说明:本文是《Vue3 储能 EMS 前端实战》第 2 篇 / 共 3 篇

适用读者:Vue3 开发者、工业物联网前端、需要实现 SCADA 监控大屏的工程师
技术栈:Vue 3.5 · Pinia · MQTT · Maotu 组态 · WebSocket
项目类型:储能云平台 EMS UI


一、EMS 的实时数据挑战

普通 Admin 系统是「用户点查询 → 发 HTTP 请求 → 渲染结果」。EMS 监控大屏则需要:

  • 秒级推送设备 SOC、电压、告警状态
  • 拓扑图上的每个图元与真实设备属性一一绑定
  • 切换站点时,旧订阅必须清理,避免脏数据和内存泄漏

本项目用 MQTT over WebSocket 承载实时通道,用 Maotu SVG 组态 承载可视化层,两者通过 HPreview 组件打通。

web/data

组态 JSON + 绑定点

MQTT Broker

mqttClient

mqttStore

HPreview

REST API

Maotu MtPreview

SVG 节点实时更新


二、MQTT 主题规范

项目 MQTT 主题遵循统一规范(见 topic.md):

/ess/{stationId}/{deviceType}/{deviceId}/web/data
/ess/{stationId}/{deviceType}/{deviceId}/web/status

deviceType 示例:5=BMS、6=簇、PCS 等,由后端字典维护。

父主题通配符形式:

/ess/{stationId}/+/+/web/data
/ess/{stationId}/+/+/web/status

三、连接与权限流程

前端不能随意订阅任意 MQTT 主题,Broker 通常有 ACL。本项目采用 先连接 → 再申请权限 → 再订阅 的三段式流程:

// stores/mqtt.ts 核心流程
const connect = async () => {
  // 1. 从后端获取 Broker 地址和临时 Token
  config.value = await getMqttConfig()
  userInfo.value = await getMqttToken()

  // 2. 根据页面协议 ws/wss 选择端口
  const brokerUrl = `${isWss ? 'wss' : 'ws'}://${host}:${port}/mqtt`

  // 3. 连接
  await mqttClient.connect(brokerUrl, { ...userInfo.value })
}

// 订阅前先申请权限(后端 ACL 校验)
const applySubscribePermission = async (topic: string) => {
  const res = await applyMqttTopicPermission([topic])
  return res.allowedTopics?.includes(topic)
}

为什么需要权限申请?
后端根据用户角色返回 allowedTopics 列表,前端只对授权主题执行 subscribe,避免越权订阅。


四、通配符 + 的客户端匹配

后端可能授权父主题 /ess/1/+/+/web/data,实际消息 topic 是 /ess/1/5/101/web/data

mqtt.js 按精确 topic 路由 handler,不会自动把 + 订阅的消息分发给子 topic 的回调。项目在 mqttClient.ts 做了客户端正则匹配

this.client.on('message', (topic, message) => {
  let handlers = this.messageHandlers.get(topic) || []

  // 遍历所有注册的 key,把 + 转成 [\d]+ 做正则匹配
  this.messageHandlers.keys().forEach(key => {
    if (key.includes('+')) {
      let regStr = key.replace(/\+/g, '[\\d]+')
      if (new RegExp(regStr).test(topic) && handlers.length === 0) {
        handlers.push(...this.messageHandlers.get(key) || [])
      }
    }
  })

  handlers.forEach(handler => handler(topic, JSON.parse(message.toString())))
})

踩坑:如果只 subscribe 精确 topic 而不做通配符路由,父主题授权后仍然收不到消息。

源码位置src/stores/mqtt.tssrc/utils/mqttClient.ts


五、组态预览 HPreview:MQTT + SVG 联动

这是项目最有特色的功能 —— 把 Maotu 组态图和 MQTT 实时数据打通。

5.1 整体流程

Maotu Preview MQTT Broker REST API HPreview Maotu Preview MQTT Broker REST API HPreview getWebtopoProjectSvgByStationId dataModel (JSON) setImportJson(data) getOperationStationNodeList 绑定点列表 [{svgNodeId, deviceType, deviceId, deviceProp}] subscribe /ess/1/5/101/web/data { soc: 85, voltage: 380 } setItemAttrByID(nodeId, "props.text.val", "85")

5.2 核心代码

// 1. 连接并申请父主题权限
const parentTopic = `/ess/${stationId}/+/+/web/data`
const parentStatusTopic = `/ess/${stationId}/+/+/web/status`

mqttStore.connect().then(async () => {
  hasDataPermit.value = await mqttStore.applySubscribePermission(parentTopic)
  hasStatusPermit.value = await mqttStore.applySubscribePermission(parentStatusTopic)
})

// 2. MQTT 消息 → 更新 SVG 节点
const handleSvgUpdate = (topic: string, data: any) => {
  const nodeInfos = subscribeObj.value[topic]
  nodeInfos.forEach((nodeInfo) => {
    mtPreviewRef.value?.setItemAttrByID(
      nodeInfo.svgNodeId,
      'props.text.val',
      data[nodeInfo.deviceProp] + ''
    )
  })
}

// 3. 权限就绪后再订阅具体 topic
watchEffect(() => {
  if (mqttStore.isConnected && hasDataPermit.value && hasStatusPermit.value) {
    subscribeArr.value.forEach(topic => {
      mqttStore.doSubscribe(topic, handleSvgUpdate)
    })
  }
})

5.3 生命周期管理

切换站点时必须:

  1. unsubscribe 旧 topic
  2. 清空 subscribeObj
  3. 重新加载组态 JSON
  4. nextTick 后调用 setImportJson(Maotu 是条件渲染,v-else 挂载前调 API 会空白)
  5. 重新拉取绑定点列表并订阅新 topic
onBeforeUnmount(() => {
  subscribeArr.value.forEach(topic => mqttStore.unsubscribe(topic))
})

源码位置src/components/HPreview/index.vue


六、Maotu 组态编辑器 —— 可视化 SCADA

6.1 技术选型

选用开源库 Maotu(0.6.5),提供:

  • MtEdit:SVG 画布编辑器
  • MtPreview:只读预览,支持缩放、拖拽、事件回调

6.2 自定义图元自动注册

工业场景需要电池、开关、仪表等专用图元,放在 customComponents/ 目录,Vite glob 自动注册

const customComponents = import.meta.glob('./customComponents/*.vue', { eager: true })

for (const key in customComponents) {
  const name = key.split('/').pop()!.split('.')[0]!
  if (!app.component(name)) {
    app.component(name, customComponents[key].default)
  }
}

新增图元只需 Drop 一个 .vue 文件,无需改注册代码。

6.3 设备绑定点位

编辑器中每个 SVG 元素可绑定真实设备属性(复用 HForm 配置化表单):

const formItems = shallowRef<HFormItem[]>([
  { type: 'select', label: '设备类型', prop: 'deviceType', options: protocol_device_type },
  { type: 'select', label: '设备',     prop: 'deviceId',   options: deviceList },
  { type: 'select', label: '属性字段', prop: 'fieldId',    options: deviceProps },
])

保存到后端 svgnode 表,预览时按绑定关系订阅 MQTT 并更新节点。

6.4 交互跳转

组态图支持点击图元跳转:

const onEventCallBack = (type, id) => {
  const { jump_type, jump_url } = info
  if (jump_type === 1) {
    router.push(jump_url)           // 路由跳转
  } else if (jump_type === 2) {
    // 动态加载组件弹层展示
    route.components.default().then(res => {
      currentComponent.value = markRaw(res.default)
      show.value = true
    })
  }
}

源码位置src/components/MtEditor/index.vuesrc/components/MtEditor/customComponents/


七、本篇小结

模块 解决的问题 关键技术
mqttStore 连接管理、权限申请 ws/wss 自适应、Pinia 状态
mqttClient 通配符 topic 路由 正则匹配 +
HPreview 组态 + 实时数据 watchEffect 订阅、setItemAttrByID
MtEditor 可视化编辑、设备绑定 import.meta.glob 自动注册

MQTT 解决了数据从哪来,Maotu 解决了数据怎么展示。下一篇讲 UI 层:HTabs 指示线、Element Plus 主题定制,以及全项目工程经验总结。

👉 下一篇Vue3 储能 EMS 前端实战(下):Element Plus 深度定制与工程总结

更多推荐