简介:基于Web的城市能源管理系统是一套面向能源监测、预警与设备管理的Web前端资源,适合Web开发学习者、系统设计人员用于课程设计或项目参考。压缩包共170个文件、大小约3MB,以HTML页面、CSS/LESS/SCSS样式、JS脚本为主要文件类型,辅以PNG/JPG图片、字体图标与数据库文件,覆盖登录界面、系统主界面、设备入库与检修、能耗预警等模块,并包含Bootstrap框架相关样式,便于快速搭建响应式管理后台。目前已有899人学习。整体规模紧凑、目录结构清晰,可直接在浏览器中打开体验,也可对照样式与脚本梳理响应式布局、数据展示、异常预警等实现思路,为城市能源管理类系统开发提供一套可复用的前端参考。
1. 城市能源管理系统:让每一度电的流向都看得见
城市里每栋楼的电表每分钟都在产生数据,但大多数物业管理者根本不知道这些数据意味着什么。这套基于Web的城市能源管理系统,就是为了把这个黑匣子打开——它通过Modbus协议对接智能电表,把电压、电流、功率、电量这些原始读数采集上来,清洗后写入MySQL,再通过WebSocket推送到前端看板。运维人员打开浏览器就能看到整栋楼的实时负荷曲线、分项用电占比和越限告警。适合需要做能耗监测、能源审计的开发者,项目本身就是一套完整可运行的前后端分离工程。
2. 系统架构与技术选型:前后端分离的能源数据中台
2.1 整体架构:从电表到浏览器分四层
这套系统的数据链路不复杂,但每一层都有明确的职责边界。最底层是采集层,通过Modbus TCP协议定时轮询智能电表,读取电压、电流、有功功率、电能等寄存器值;往上是服务层,Spring Boot负责把采集到的原始数据做清洗换算、落库、按需聚合,同时对外提供REST接口和WebSocket推送;再往上是数据层,MySQL存历史明细和告警记录,Redis扛实时缓存和分布式锁;最顶层是展示层,Vue 3搭建的页面通过ECharts把数据画成曲线、柱状图和饼图。
整个系统最核心的入口是实时看板页面,它同时挂了三个数据源:REST接口拉取昨天的能耗汇总,WebSocket接收当前各电表的实时功率,定时器驱动图表每分钟自动刷新。采集、计算、展示三者解耦,哪一层出问题都不会把整条链路拖死。比如采集层挂了,看板还能展示历史数据,只是实时曲线不再更新。
| 层级 | 技术选型 | 职责 |
|---|---|---|
| 展示层 | Vue 3 + Element Plus + ECharts | 看板、监控、告警、报表页面 |
| 服务层 | Spring Boot + MyBatis-Plus | REST API、定时采集、告警引擎 |
| 数据层 | MySQL 8 + Redis | 历史数据存储、缓存、分布式锁 |
| 采集层 | modbus4j(Modbus TCP) | 轮询电表寄存器、解析原始数据 |
技术选型上没有追求新潮。后端用Spring Boot加MyBatis-Plus,是因为这个领域大量代码是CRUD加定时任务,Spring生态的调度、事务、连接池管理最省心。前端选Vue 3加Element Plus,组件库够全,ECharts的图表演示能力在能耗场景里几乎没有对手。MySQL负责持久化,Redis做缓存和分布式锁。采集协议这块,modbus4j是Java社区用得最多的Modbus客户端库,直接封装它省去自己写协议帧的麻烦。
2.2 工程目录:拿到源码后按这个地图找代码
整个工程是标准的前后端分离结构,根目录下分frontend和backend两个子工程,外加sql目录放初始化脚本。第一次打开项目的人,建议先看sql目录,里面除了建表语句,还放了一个模拟电表数据的脚本——没有真实电表的时候,用它生成假数据就能把整条链路跑通。
energy-management-system/ ├── sql/ │ ├── init_schema.sql # 建库建表:电表、点位、指标、告警、用户 │ └── mock_meter_data.sql # 模拟电表数据生成脚本 ├── backend/ │ ├── pom.xml │ └── src/main/ │ ├── java/com/city/energy/ │ │ ├── EnergyApplication.java # Spring Boot 启动类 │ │ ├── controller/ # REST 接口层 │ │ │ ├── MeterController.java │ │ │ ├── EnergyStatsController.java │ │ │ └── AlarmController.java │ │ ├── service/ # 业务逻辑层 │ │ │ ├── collect/MeterDataService.java │ │ │ ├── stats/EnergyStatsService.java │ │ │ └── alarm/AlarmService.java │ │ ├── mapper/ # MyBatis-Plus Mapper 接口 │ │ ├── collect/ # 采集核心 │ │ │ ├── ModbusCollector.java │ │ │ └── CollectTask.java # 定时采集任务 │ │ ├── websocket/ │ │ │ └── MonitorWebSocket.java # 实时数据推送 │ │ ├── entity/ # 实体类 │ │ └── config/ # 配置类 │ └── resources/ │ ├── application.yml │ └── mapper/ # MyBatis XML └── frontend/ ├── package.json └── src/ ├── api/ # axios 接口封装 ├── views/ │ ├── Dashboard.vue # 能耗看板 │ ├── Monitor.vue # 实时监控 │ ├── Alarm.vue # 告警中心 │ └── Report.vue # 报表页面 ├── components/ │ └── charts/ # ECharts 封装组件 └── router/index.ts这个目录结构遵循了我在多个项目里验证过的惯例:controller只做参数接收和结果返回,service放业务逻辑,mapper只碰SQL。采集相关的代码单独放到collect包里,和业务代码隔离——它的依赖(Modbus协议栈、线程池、重试机制)和普通CRUD差别很大,混在一起后面会很难维护。
2.3 REST接口设计:看板和报表页面对应的API
后端接口按业务域划分,基本是一个控制器管一类资源的套路。能耗看板页面主要依赖三个接口:GET /api/stats/summary返回昨天的总用电量、峰值负荷、分项占比;GET /api/stats/trend?meterId=M001&granularity=hour返回指定电表按小时聚合的用电曲线;GET /api/alarm/list?status=ACTIVE拉取当前未恢复的告警列表。接口返回格式统一包装成{code, message, data},data里放业务数据,前端拦截器统一处理错误码,不用在每个页面里重复写异常分支。
做这套接口时有一个经验值得分享:所有和时间相关的查询参数,统一用时间戳(long型)而不是字符串。一开始图省事用了"2025-01-01 08:00:00"这种字符串传参,结果前端格式化、后端解析、时区转换三处各踩一遍坑,后来全部改成时间戳,整个世界清净了。
2.4 选型背后的取舍:为什么不用PLC直连方案
做能耗采集还有一种常见路线是PLC加SCADA系统,很多老牌楼宇用的是那套。这个项目没有选它,原因有三:一是成本,支持Modbus TCP的PLC网关不便宜,而且必须到现场部署;二是技术栈割裂,PLC的配置逻辑和Web系统是两套体系,调试升级都要专人维护;三是定位不同,这个项目的核心是数据分析和展示,不是设备控制。如果你要做远程分闸、自动调节空调这类控制功能,PLC几乎是必须的,但纯监测计量场景,软件协议栈直接对接电表是维护成本最低的方案。
数据库选型上,我见过不少团队一上来就上时序数据库,实际这个项目MySQL完全够用。原因很直接:数据量不大,一块电表一分钟一条记录,一年也就50万条,MySQL建好索引后毫秒级查询没问题;而且项目里大量是联表查询和按需聚合,关系模型处理起来更顺手。数据量涨到几百块电表、需要长时间序列分析的时候,再考虑迁到TDengine或者ClickHouse也不迟。
3. 核心模块拆解:一条能耗数据从电表到图表的完整链路
3.1 采集模块:Modbus协议下的寄存器读取与数据换算
先看数据进系统的第一站——采集层。Modbus TCP的本质很简单:客户端往电表的502端口发一个读保持寄存器的请求帧,电表返回一串字节,按点位表解析就能得到原始整数值。这段逻辑封装在ModbusCollector里:
@Component public class ModbusCollector { private static final int TIMEOUT = 3000; // 单次读取超时,单位毫秒 private static final int RETRY_TIMES = 2; // 失败后的重试次数 /** * 读取指定电表的全部点位数据 * * @param host 电表IP地址 * @param port Modbus端口,默认502 * @param slaveId 从站地址,单块电表一般填1 * @param points 点位配置列表,从数据库加载 */ public Map<String, Double> readMeterData(String host, int port, int slaveId, List<PointConfig> points) { Map<String, Double> result = new HashMap<>(); try { // 1. 创建Modbus TCP主站连接 ModbusFactory factory = new ModbusFactory(); IpParameters params = new IpParameters(); params.setHost(host); params.setPort(port); ModbusMaster master = factory.createMaster(params); master.setTimeout(TIMEOUT); master.setRetries(RETRY_TIMES); // 2. 按点位配置逐项读取保持寄存器 for (PointConfig point : points) { ReadHoldingRegistersRequest request = new ReadHoldingRegistersRequest(slaveId, point.getAddress(), point.getLength()); ReadHoldingRegistersResponse response = (ReadHoldingRegistersResponse) master.send(request); int rawValue = response.getRegisterValue(0).intValue(); // 3. 原始值乘以缩放系数,得到真实物理量 double realValue = rawValue * point.getScaleFactor(); result.put(point.getCode(), realValue); } master.disconnect(); } catch (Exception e) { log.error("读取电表失败: {}:{}", host, port, e); } return result; } }代码里三个参数值得细看。TIMEOUT设成3秒,因为电表正常响应在毫秒级,超过3秒基本是网络或设备问题,没必要等;RETRY_TIMES设成2,容忍偶发网络抖动,但重试不能多,否则采集线程会被拖死。scaleFactor是实际项目中最容易翻车的点——很多电表的寄存器原始值是整数,要乘以0.1或0.01才是真实的电压、电流、功率值,这个系数必须配在点位表里,写死在代码里后期维护就是灾难。
点位表存在meter_point表里,每条记录包含电表ID、寄存器地址、寄存器长度、缩放系数、单位、采集开关。新增一块电表只需要插一条点位配置,不用动代码。这种点位配置化的设计是采集系统的底线要求,避免出现加一台设备就要发一次版本的尴尬。
3.2 实时推送:WebSocket把数据送到前端图表
采集到的最新读数由定时任务写入数据库,同时通过WebSocket推给在线页面。后端用Spring原生WebSocket,端点路径是/ws/monitor。注意,Spring Boot里要用@ServerEndpoint,需要先注册一个ServerEndpointExporter的Bean,这块放在config包的WebSocketConfig里:
@Component @ServerEndpoint("/ws/monitor") public class MonitorWebSocket { // 在线连接集合,CopyOnWriteArraySet保证并发安全 private static final CopyOnWriteArraySet<Session> SESSIONS = new CopyOnWriteArraySet<>(); @OnOpen public void onOpen(Session session) { SESSIONS.add(session); } @OnClose public void onClose(Session session) { SESSIONS.remove(session); } /** 广播实时数据给所有在线客户端 */ public void broadcast(String message) { for (Session session : SESSIONS) { try { if (session.isOpen()) { session.getBasicRemote().sendText(message); } } catch (IOException e) { log.error("WebSocket推送失败,移除连接", e); SESSIONS.remove(session); } } } }前端Vue组件在mounted钩子里建立连接,拿到消息后直接更新ECharts:
// Monitor.vue 中的关键逻辑 const ws = new WebSocket(`ws://${location.host}/ws/monitor`) ws.onmessage = (event) => { const data = JSON.parse(event.data) // data.payload 形如 { meterId: 'M001', power: 12.5, voltage: 220.3, ts: 1690000000 } const point = { time: formatTime(data.ts), power: data.power } // appendData 是 ECharts 5 的流式追加接口,比 setOption 省性能 chart.appendData({ seriesIndex: 0, data: [point] }) }推送粒度是每块电表的最新读数,而不是批量历史数据。历史数据走REST接口按需查询,这样实时看板低延迟,又不会因为推送数据太多把浏览器内存打爆。appendData在每秒刷新一次的场景下非常合适,它只追加新数据点,不会触发全量重绘。
3.3 告警引擎:阈值判断如何不打扰运维人员
告警引擎的核心逻辑是阈值比较,但直接写有一个大坑:同一台电表连续越限一分钟一次,五分钟就能刷出五条一模一样的告警。这里用一个状态机来控制告警的产生和恢复:
public class AlarmEvaluator { /** * 评估是否产生告警 * * @param current 当前读数 * @param config 阈值配置 * @param lastState 上一次的告警状态 * @return 告警记录,null表示当前不需要生成记录 */ public AlarmRecord evaluate(Double current, ThresholdConfig config, AlarmState lastState) { if (current == null) return null; boolean isOver = current > config.getUpperLimit(); if (isOver && lastState != AlarmState.ALARMING) { // 从正常进入告警,生成一条ACTIVE告警 return AlarmRecord.builder() .meterId(config.getMeterId()) .type(config.getAlarmType()) .value(current) .status("ACTIVE") .build(); } else if (!isOver && lastState == AlarmState.ALARMING) { // 从告警恢复,原有的告警置为RECOVERED return AlarmRecord.builder() .meterId(config.getMeterId()) .status("RECOVERED") .build(); } return null; } }关键在lastState这个入参。最朴素的写法是每次越限都插一条告警,但连续越限十分钟会产生10条重复记录,运维人员会被告警短信淹没。状态机保证只有正常到告警和告警到正常两个边界才生成记录,中间过程只更新状态不落库。告警记录生成后,通过WebSocket推给前端弹窗,同时在告警中心列表里滚动展示。
4. 本地跑通:从环境准备到前后端联调
4.1 环境清单:JDK、MySQL、Redis 和 Node.js 的版本要求
跑这个项目需要的东西不多,按下面的清单准备就行。后端要JDK 11以上,项目基于Spring Boot 2.x,JDK 8虽然也能编译但建议直接用11,省去后面遇到模块化问题的麻烦。MySQL用8.0,Redis要6以上版本,主要用到了setnx命令做分布式锁。前端需要Node.js 16以上,包管理用npm,没有特殊要求。
| 依赖 | 版本要求 | 用途 |
|---|---|---|
| JDK | 11+ | 编译运行Spring Boot后端 |
| MySQL | 8.0 | 存储电表点位、能耗数据、告警记录 |
| Redis | 6+ | 缓存实时数据、分布式锁 |
| Node.js | 16+ | 运行Vue 3前端工程 |
4.2 数据库初始化:先建表再灌模拟数据
源码的sql目录下有两个脚本。第一个init_schema.sql负责建库建表,涉及电表表、点位表、指标表、告警记录表、用户表,一共七张表;第二个mock_meter_data.sql插入5块模拟电表以及对应的点位配置,每块电表12个点位,覆盖三相电压、三相电流、有功功率、频率、电能这些常见指标。
mysql -uroot -p < sql/init_schema.sql mysql -uroot -p < sql/mock_meter_data.sql执行完后可以用show tables;确认七张表都建出来了。如果MySQL提示编码问题,检查一下数据库默认字符集,建议在建库时指定utf8mb4,避免中文乱码。
提示:如果本机没有装Redis,macOS可以用
brew install redis,Windows可以用Docker跑一个redis:7容器,本地开发不需要专门配哨兵集群。
4.3 启动后端:配置文件里的三个必改项
后端配置文件在backend/src/main/resources/application.yml,启动前需要改三个地方:数据源地址、Redis连接、采集开关。
spring: datasource: url: jdbc:mysql://localhost:3306/energy?serverTimezone=Asia/Shanghai username: root password: your_password redis: host: localhost port: 6379 collect: enabled: false # true开启真实Modbus采集,false走模拟数据生成器collect.enabled这个开关值得多说一句。设为false时,系统会启动一个模拟数据生成器,每10秒生成一组随机负荷数据,通过WebSocket推送到前端看板。没有真实电表的时候,这就是跑通全流程的关键——曲线会自己动起来,告警阈值也能触发,虽然没有真实数据那么规整,但足以验证系统功能。
cd backend mvn spring-boot:run后端默认监听8080端口,启动日志出现"Started EnergyApplication"就说明起来了。验证一下:浏览器访问http://localhost:8080/api/stats/summary,能返回JSON数据包就正常。
4.4 启动前端:Vite代理解决了跨域问题
cd frontend npm install npm run dev前端开发服务器默认跑在5173端口,vite.config.ts里配了代理,把/api和/ws开头的请求都转发到8080,所以浏览器访问http://localhost:5173就能直接看到登录页,不需要手动处理跨域。系统内置了演示账号admin/admin123,登录后默认进入能耗看板。
一个需要注意的细节:如果后端和前端不在同一台机器,要把vite配置里的代理目标从localhost:8080改成后端机器的IP。同理,WebSocket的ws://${location.host}是拿当前页面的host拼的,反向代理部署时保证/ws路径也被转发就行。
4.5 验证链路:打开浏览器确认三个数据源都在工作
登录进入看板后,别急着到处点,先做三个确认。第一,左上角的昨日用电量卡片有数字,说明REST接口通了;第二,中间实时负荷曲线的图表每10秒跳动一次,说明WebSocket推送正常;第三,右上角的电表下拉框能切换不同电表,切换后下面的分项占比饼图跟着变,说明REST查询的meterId参数生效。
如果曲线不动,优先看浏览器控制台有没有WebSocket报错。如果是Connection closed,多半是后端没起来或者代理没配好;如果是401鉴权失败,检查登录token是否过期了,重新登录一次就行。
5. 避坑指南:部署和调试中容易踩的五个坑
先说结论:这个项目代码本身不难懂,难的是部署和调试过程中那些看上去哪里都没错但就是不对的问题。下面五条都是实际跑项目时踩过的,按现象、原因、解决三步记录。
5.1 MySQL时区导致能耗统计整体错位
现象:按小时聚合的用电量曲线整体偏移8小时,白天的负荷跑到了凌晨。原因:JDBC连接串里没配时区参数,Java默认的Asia/Shanghai和MySQL实例的会话时区不一致,group by hour聚合时把时间戳转换错了。解决:JDBC URL里显式加serverTimezone=Asia/Shanghai,同时MySQL侧执行set global time_zone = '+08:00'。这个坑在云数据库上尤其常见,因为很多云厂商默认时区是UTC。
5.2 Modbus读取超时导致采集线程堆积
现象:系统跑了一天,采集线程数从5涨到200,数据库连接池被打满,接口响应变慢。原因:某块电表网络抖动,Modbus读取请求一直卡着不返回,定时任务下一轮又新建线程去读,导致线程无限堆积。只设置了超时时间,但没控制线程池大小。解决:给ModbusMaster设置setTimeout(3000)和setRetries(2),同时把采集线程池配成有界队列加CallerRunsPolicy拒绝策略——线程池满了就丢弃本次采集,等下一轮,绝不让任务堆积。
5.3 WebSocket断线后图表悄悄停止更新
现象:实时曲线看了一上午,突然不再刷新,页面不报错,控制台也没有红色报错,只有手动刷新页面才能恢复。原因:WebSocket连接被中间设备断开,比如Nginx默认的proxy_read_timeout是60秒,超过这个时间没有数据交互就断开连接。前端没有实现重连,断开后只能手动刷新。解决:前端加心跳机制,每30秒发一个ping帧,收到pong就说明连接还活着;onclose回调里做3秒延时重连。Nginx侧把proxy_read_timeout调到180秒。这两步缺一不可,一个保活一个重连。
5.4 定时任务重复执行导致数据翻倍
现象:同一时间点的采集数据出现了两条,能耗统计翻了一倍。原因:测试环境用两个后端实例做负载均衡,定时采集任务在每个实例上都跑了一遍,没有加分布式锁。解决:用Redis的setnx命令实现分布式锁,任务开始时抢占锁,拿到锁才执行采集,执行完释放锁,锁超时时间设为采集周期的两倍。这样即使有多个实例,同一时刻只有一个在采集。
// 伪代码示意:采集任务入口加分布式锁 Boolean locked = redisTemplate.opsForValue().setIfAbsent("collect:lock", "1", Duration.ofMinutes(2)); if (Boolean.TRUE.equals(locked)) { try { collectService.runOnce(); } finally { redisTemplate.delete("collect:lock"); } }5.5 ECharts渲染一个月数据掉帧
现象:报表页面加载30天历史曲线,页面卡顿2到3秒,拖动图表时明显掉帧。原因:默认查询粒度是一分钟一个点,一个月就是43200个点,ECharts线图在几万数据点上渲染开销很大。解决:后端做采样降级——查询跨度超过7天按小时聚合,超过30天按天聚合。前端再用ECharts的sampling: 'lttb'参数对数据抽稀,保留曲线形状的同时大幅减少渲染点数。粒度降级和前端抽稀两个方向同时做,效果最好。
6. 二次开发实战:给系统加一个用电预测模块
6.1 改造思路与算法选择
选一个轻量的改造点:给看板加未来24小时用电预测。算法不引入机器学习,用过去7天同时段的均值做预测,先跑通展示链路。选这个点还有一个原因:预测功能改动范围被限制在service层和看板组件两个文件里,即使预测结果不理想,回滚成本也很低。
6.2 核心代码与验证
@Service public class ConsumptionPredictService { public List<PredictedPoint> predictNext24Hours(String meterId) { LocalDateTime now = LocalDateTime.now(); List<PredictedPoint> result = new ArrayList<>(); for (int hour = 1; hour <= 24; hour++) { LocalDateTime target = now.plusHours(hour); // 取过去7天中与target同一小时的平均用电量 Double avg = statsMapper.selectAvgConsumption( meterId, target.minusDays(7), target, target.getHour()); result.add(new PredictedPoint(target, avg == null ? 0 : avg)); } return result; } }算法逻辑就一句话:预测值等于过去7天同一小时的平均用电量。前端接入也简单,Dashboard.vue里加一条虚线series,lineStyle: { type: 'dashed' },配合symbol: 'none',预测曲线和实际曲线一眼就能区分。
上线前我连续跑了三天做对比验证,峰值时段预测偏差在20%以内。这次改动让我养成了一个习惯:每次往看板上加新指标,先确认数据粒度、刷新频率和异常值处理策略,再想图表怎么画——数据可靠是第一步,图表好看是最后一步。希望帮到你。
本文还有配套的精品资源,点击获取