Vue3 储能 EMS 前端实战:配置化组件 + MQTT 实时组态 + Element Plus 深度定制

Vue3 储能 EMS 前端实战:配置化组件 + MQTT 实时组态 + Element Plus 深度定制

适用读者 :Vue3 中高级开发者、工业物联网 / 能源管理系统前端工程师

技术栈 :Vue 3.5 · Vite 8 · Element Plus · Pinia · MQTT · Maotu 组态 · ECharts · Three.js

项目类型:储能云平台 EMS UI


一、前言:为什么这个项目值得写?

储能能源管理系统(EMS)前端和普通后台管理系统有本质区别:

维度 普通 Admin EMS 前端
数据特征 请求-响应,静态为主 秒级实时推送
页面形态 表格 + 表单 组态拓扑图 + 实时曲线 + 3D 模型
交互复杂度 CRUD 设备控制、告警联动、多站点切换
页面数量 几十页 上百页,高度重复

本项目在工程上做了三件「难而正确」的事:

  1. 配置驱动 :用 HFormItem[] / HColumn[] 声明式配置,替代大量重复模板代码
  2. 实时组态:Maotu SVG 编辑器 + MQTT 推送,实现 SCADA 级监控大屏
  3. UI 体系化:对 Element Plus 表格、Tabs、Dialog 做深度样式覆盖,形成统一设计语言

下面按「架构 → 核心难点 → 实现细节 → 踩坑总结」展开。


二、整体架构一览

graph TB subgraph 视图层 A[业务页面 100+] B[HForm 配置化表单] C[HTable 配置化表格] D[HPreview 组态预览] E[HTabs 导航] end subgraph 组件层 F[MtEditor 组态编辑] G[Echart / GlbViewer / HAmap] end subgraph 状态与通信 H[Pinia Store] I[Axios HTTP] J[MQTT WebSocket] end subgraph 后端 K[REST API] L[MQTT Broker] end A --> B & C & D & E D --> F A --> G B & C --> H A --> I --> K D --> J --> L

工程化配套

  • Vite 8 + unplugin-auto-importrefcomputed 等自动导入,减少样板代码
  • vue-i18n :国际化,$t('1001142') 形式的多语言 key
  • UnoCSS + SCSS:原子化 + 主题变量双层样式体系
  • postcss-pxtorem + amfe-flexible:大屏适配

三、难点一:配置化表单 HForm ------ 用 JSON 描述 UI

3.1 设计思路

传统写法:每个页面手写 <el-form> + 十几个 <el-form-item>,字段一多就失控。

本项目抽象出全局类型 HFormItem(定义在 custom-declare.d.ts),用数组配置描述整个表单:

typescript 复制代码
// 典型用法:查询表单
const queryFormItems = shallowRef<HFormItem[]>([
  { label: '设备类型', type: 'select', prop: 'deviceType', options: deviceTypes, span: 6 },
  { label: '设备名称', type: 'input',  prop: 'deviceName', span: 6 },
  { label: '时间范围', type: 'daterange', prop: 'dateRange', span: 6 },
])

模板侧只需一行:

vue 复制代码
<h-form :form-items="queryFormItems" :model="queryFormData" isquery @search="handleSearch" />

3.2 核心实现:layoutItems 自动换行算法

HForm 不是简单 v-for,而是内置栅格换行布局引擎

typescript 复制代码
// 核心逻辑(简化版)
const layoutItems = computed(() => {
  const result = []
  let i = 0
  while (i < items.length) {
    const rowItems = []
    let rowSpan = 0
    // 按 span 累加,满 24 换行;span=24 独占一行
    while (i < items.length) {
      const span = item.span || (isquery ? 6 : 12)
      if (span === 24) { /* 独占一行 */ break }
      if (rowSpan + span > 24) break
      rowItems.push(item)
      rowSpan += span
      i++
      if (rowSpan === 24) break
    }
    // 为每列计算对齐类:set-left / set-center / set-right
    rowItems.forEach((item, index) => {
      result.push({ item, span, alignClass: getAlignClass(index, spans, rowSpan) })
    })
  }
  return result
})

三种模式

模式 触发条件 行为
isquery 查询表单 默认 span=6,前 3 项 + 搜索按钮,超出可展开
编辑表单 默认 span=12,左/中/右对齐,固定 340px 输入宽度
disabled 查看详情 禁用态样式,空值隐藏 placeholder

3.3 校验体系:函数式 Rule + Decimal.js

数字字段支持 min / max / step 校验,步长用 Decimal.js 避免浮点误差:

typescript 复制代码
// step 校验:value 必须是 step 的整数倍
rules.push(createRule((value) =>
  new Decimal(value).mod(item.attrs!.step!).toNumber() !== 0
    ? `请输入 ${item.attrs!.step} 的倍数`
    : undefined
))

自定义校验支持函数数组,比 Element Plus 原生 rules 更简洁:

typescript 复制代码
rules: [
  (val) => val < 0 ? '不能为负数' : undefined,
  (val) => val > 100 ? '不能超过100' : undefined,
]

3.4 扩展点:Slot 插槽

配置覆盖不了的场景,用 #prop名 插槽:

vue 复制代码
<h-form :form-items="formItems" :model="formData">
  <template #customField>
    <my-special-component v-model="formData.customField" />
  </template>
</h-form>

源码位置src/components/HForm/index.vuecustom-declare.d.ts


四、难点二:配置化表格 HTable ------ 自适应高度 + 列显隐

4.1 useHTable Composable 设计

表格逻辑从组件中抽离到 useHTable.ts,实现逻辑复用HTableCleanHTableBorder 共用同一套逻辑)。

亮点 1:ResizeObserver 自动计算表格高度

EMS 页面常见布局:顶部 Tab + 查询表单 + 表格撑满剩余空间。手动算高度很痛苦,项目用 ResizeObserver 监听父容器:

typescript 复制代码
const getTableHeight = () => {
  // 遍历兄弟节点,累加高度
  for (let el of siblingNodes) {
    if (el !== tableContainer.value) {
      sum += el.getBoundingClientRect().height + marginTop + marginBottom
    }
  }
  // 父容器高度 - padding - 兄弟高度 = 表格可用高度
  height.value = Math.max(parentHeight - sum, minHeight ?? 280)
}

亮点 2:列宽智能计算

支持 "10z" 这种写法,表示 10 个字符宽度,内部用 Canvas measureText 精确测量:

typescript 复制代码
if (/\d+[zZ]{1}/.test(col.width)) {
  col.width = calculateTextWidth("Z".repeat(parseInt(col.width))) + 24
}

亮点 3:列显隐设置

setting=true 时弹出 Popover,用户勾选可见列,存入 selectedColumns,持久化可扩展 localStorage。

4.2 HTableClean:Element Plus 表格边框的深度覆盖

这是近期改动较多的视觉难点 。Element Plus 默认 border 表格四边都有线,设计稿要求:

  • 去掉单元格右边框
  • 表头用伪元素做列间分隔线(高度 26px,垂直居中)
  • 固定列在所有滚动状态下都显示分隔线
scss 复制代码
.htable-clean {
  :deep(td.ep-table__cell) {
    border-right: none !important;
    border-bottom: 1px solid var(--ep-table-border-color) !important;
  }

  :deep(th.ep-table__cell) {
    border-right: none !important;
    position: relative;

    &::after {
      content: " ";
      width: 1px;
      height: 26px;
      position: absolute;
      top: 50%;
      right: 0;
      transform: translateY(-50%);
      background-color: var(--ep-table-border-color);
    }
  }

  // 关键:覆盖 EP 在不同 is-scrolling-xxx 状态下的固定列边框
  :deep(.ep-table.is-scrolling-none),
  :deep(.ep-table.is-scrolling-left),
  :deep(.ep-table.is-scrolling-right),
  :deep(.ep-table.is-scrolling-middle) {
    td.ep-table-fixed-column--left.is-last-column,
    th.ep-table-fixed-column--left.is-last-column {
      border-right: 1px solid var(--ep-table-border-color) !important;
    }
  }
}

踩坑is-scrolling-xxx 类名在 .ep-table 上,不在 .htable-clean 上,选择器层级要写对。

源码位置src/components/HTable/useHTable.tssrc/components/HTable/HTableClean.vue


五、难点三:MQTT 实时数据 ------ 权限订阅 + 通配符匹配

5.1 主题规范

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

bash 复制代码
/ess/{stationId}/{deviceType}/{deviceId}/web/data
/ess/{stationId}/{deviceType}/{deviceId}/web/status

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

5.2 连接与权限流程

typescript 复制代码
// 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)
}

为什么需要权限申请?

MQTT Broker 通常有 ACL,前端不能随意订阅任意主题。先调 REST API 申请,后端返回允许的主题列表,再 subscribe

5.3 通配符 + 的客户端匹配

后端可能授权父主题 /ess/1/+/+/web/data,但 mqtt.js 的 subscribe('+') 不会把消息路由到具体 topic 的 handler。项目在 mqttClient.ts 做了客户端正则匹配

typescript 复制代码
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())))
})

5.4 组态预览 HPreview:MQTT + SVG 联动

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

sequenceDiagram participant Page as HPreview participant API as REST API participant MQTT as MQTT Broker participant SVG as Maotu Preview Page->>API: getWebtopoProjectSvgByStationId API-->>Page: dataModel (JSON) Page->>SVG: setImportJson(data) Page->>API: getOperationStationNodeList API-->>Page: 绑定点列表 [{svgNodeId, deviceType, deviceId, deviceProp}] Page->>MQTT: subscribe /ess/1/5/101/web/data MQTT-->>Page: { soc: 85, voltage: 380 } Page->>SVG: setItemAttrByID(nodeId, &#34;props.text.val&#34;, &#34;85&#34;)

核心代码:

typescript 复制代码
const handleSvgUpdate = (topic: string, data: any) => {
  const nodeInfos = subscribeObj.value[topic]
  nodeInfos.forEach((nodeInfo) => {
    // 把 MQTT 数据映射到 SVG 节点属性
    mtPreviewRef.value?.setItemAttrByID(
      nodeInfo.svgNodeId,
      'props.text.val',
      data[nodeInfo.deviceProp] + ''
    )
  })
}

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

生命周期管理 :切换站点时先 unsubscribe 旧主题,再加载新组态、订阅新主题,避免内存泄漏和脏数据。

源码位置src/stores/mqtt.tssrc/utils/mqttClient.tssrc/components/HPreview/index.vue


六、难点四:Maotu 组态编辑器 ------ 可视化 SCADA

6.1 技术选型

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

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

6.2 自定义组件自动注册

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

typescript 复制代码
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 元素可绑定真实设备属性:

typescript 复制代码
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 交互跳转

组态图支持点击跳转:

typescript 复制代码
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/


七、难点五:HTabs 自定义指示线 ------ 突破 Element Plus 限制

Element Plus Tabs 的 active-bar 是内置动画条,设计稿要求下划线指示器且宽度跟随 Tab 文字。

7.1 方案:CSS 变量 + JS 同步

scss 复制代码
// styles/element/index.scss
@mixin h-tabs-line-nav {
  &::after {
    content: '';
    position: absolute;
    bottom: 0;
    left: var(--h-tabs-bar-x, 0);
    width: var(--h-tabs-bar-width, 0);
    height: 2px;
    background: #2897ff;
    transition: left 0.2s, width 0.2s;
  }
}
typescript 复制代码
// HTabs/index.vue
const applyIndicator = (item: HTMLElement) => {
  navEl.style.setProperty('--h-tabs-bar-x', `${item.offsetLeft}px`)
  navEl.style.setProperty('--h-tabs-bar-width', `${item.offsetWidth}px`)
  navEl.classList.add('has-indicator')
}

7.2 监听 DOM 变化

Tab 动态增删、文字变化时指示线会错位,用 MutationObserver + ResizeObserver + requestAnimationFrame 三重保障:

typescript 复制代码
const observer = new MutationObserver(() => scheduleSync())
observer.observe(navEl, { attributes: true, childList: true, subtree: true })

const resizeObserver = new ResizeObserver(() => {
  clearTimeout(resizeTimer)
  resizeTimer = setTimeout(scheduleSync, 80)
})

7.3 页面模式:Tab 导航 + keep-alive 内容区

vue 复制代码
<h-tabs v-model="activeTab">
  <el-tab-pane v-for="tab in tabs" :label="tab.label" :name="tab.name" />
</h-tabs>
<div class="contentBox h-tabs-page-content">
  <keep-alive>
    <component :is="activeComponent" />
  </keep-alive>
</div>

h-tabs-page-contentcalc(100% - var(--h-tabs-header-height)) 撑满剩余高度,配合 HTable 的 ResizeObserver 形成完整布局链。

源码位置src/components/HTabs/index.vuesrc/styles/element/index.scss


八、其他特色点速览

模块 特色
Echart 封装 统一主题、resize 监听、liquidfill / GL 3D 图表
GlbViewer Three.js 加载 3D 储能设备模型
HAmap / 天地图 站点地理分布
Decimal.js 电量、金额计算避免 JS 浮点问题
exceljs + html2canvas 报表导出 PDF/Excel
权限 Tabs Tab 列表按 permissions 过滤,无权限不渲染
v-loadingh 自定义 Loading 指令,半透明遮罩 + 品牌色 spinner

九、工程经验总结

✅ 值得借鉴

  1. 配置驱动 + Slot 扩展:80% 页面用配置,20% 特殊场景用插槽,平衡效率与灵活性
  2. Composable 抽离useHTableuseDictsuseLoad 让组件保持轻薄
  3. 全局类型声明HFormItemHColumncustom-declare.d.ts,IDE 自动补全体验好
  4. MQTT 生命周期闭环:connect → 申请权限 → subscribe → unsubscribe → disconnect
  5. 样式分层 :UnoCSS 原子类 + SCSS 主题变量 + :deep() 覆盖 EP 组件

⚠️ 踩坑记录

问题 原因 解法
固定列边框消失 EP 滚动状态类名层级不对 选择器写到 .ep-table.is-scrolling-xxx
MQTT 通配符不生效 mqtt.js 不支持客户端 + 匹配 自写正则路由
Maotu 条件渲染后空白 v-else 挂载前调 API nextTick 后再 setImportJson
Tab 指示线闪烁 同步读 offsetWidth requestAnimationFrame 延迟同步
表格高度为 0 父容器无明确高度 Layout 链路上每层 flex:1; min-height:0

十、结语

这个 EMS 前端项目的核心价值,不在于用了多少库,而在于针对工业场景做了正确的抽象

  • 上百个 CRUD 页面 → HForm / HTable 配置化
  • 实时监控需求 → MQTT + 组态联动
  • 统一 UI 规范 → Element Plus 深度主题定制

如果你也在做 IoT / 能源 / 工业互联网前端,希望本文的架构思路和代码片段能帮你少踩几个坑。

相关推荐
半岛@少年3 天前
Webpack在前端项目中究竟发挥什么作用?
前端·webpack·前端工程化
米丘7 天前
微前端 Micro-App 实践
微服务·前端框架·前端工程化
初心丨哈士奇8 天前
用 AI 自动生成前端代码影响范围报告:从 CI 到测试用例
ci/cd·aigc·前端工程化
Shiy_8 天前
前端模块化设计实战:从 Vue3 Composition API 到 Monorepo 工程化
架构·前端工程化
打呵欠的猫9 天前
【1】用了3个月AI写代码,我每天在重复同一个错误(90%的人都在犯)
ai编程·前端工程化
梵得儿SHI15 天前
Vue 项目实战与性能优化:工程化与协作全指南(规范 + 配置 + 协作 + 文档)
前端·vue.js·代码规范·eslint·团队协作·前端工程化·前端架构
Linsk17 天前
一个案例教你彻底搞明白`AbortController` 、`AbortSignal`
vite·前端工程化
NIIBLE17 天前
全栈日记之工程化设计(webpack)
前端·webpack·前端工程化
canonical_entropy19 天前
超越Harness Engineering: AGE 应用开发模板介绍
aigc·ai编程·前端工程化