Browse Source

对接电表,增加MQTT

jingbo 3 tháng trước cách đây
mục cha
commit
1fb7818e96
30 tập tin đã thay đổi với 17826 bổ sung và 724 xóa
  1. 2 0
      db/mysql/add_mes_device_image_path.sql
  2. 32 0
      db/mysql/add_mes_device_oee_screen.sql
  3. 28 0
      db/mysql/add_mes_employee_menu.sql
  4. 32 0
      db/mysql/add_mes_material_screen.sql
  5. 32 0
      db/mysql/add_mes_quality_screen.sql
  6. 20 0
      db/mysql/mes_adw300_data.sql
  7. 13411 0
      db/mysql/mes_cloud.sql
  8. 2852 717
      mes_cloud.sql
  9. BIN
      output/factory-layout-renamed.png
  10. BIN
      output/imagegen/factory-layout-labels-edited-v2.png
  11. BIN
      output/imagegen/factory-layout-labels-edited.png
  12. 5 0
      pom.xml
  13. 27 0
      src/main/java/com/jeesite/modules/mes/dao/MesAdw300DataDao.java
  14. 141 0
      src/main/java/com/jeesite/modules/mes/entity/MesAdw300Data.java
  15. 54 0
      src/main/java/com/jeesite/modules/mes/mqtt/Adw300Message.java
  16. 31 0
      src/main/java/com/jeesite/modules/mes/mqtt/Adw300MqttMessageHandler.java
  17. 94 0
      src/main/java/com/jeesite/modules/mes/mqtt/Adw300MqttProperties.java
  18. 118 0
      src/main/java/com/jeesite/modules/mes/mqtt/Adw300MqttSubscriber.java
  19. 80 0
      src/main/java/com/jeesite/modules/mes/mqtt/Adw300PayloadParser.java
  20. 41 0
      src/main/java/com/jeesite/modules/mes/mqtt/Adw300Point.java
  21. 261 0
      src/main/java/com/jeesite/modules/mes/service/MesAdw300DataService.java
  22. 1 1
      src/main/java/com/jeesite/modules/mes/web/MesDeviceController.java
  23. 16 0
      src/main/java/com/jeesite/modules/mes/web/MesProductController.java
  24. 13 0
      src/main/resources/config/application.yml
  25. 74 0
      src/main/resources/mappings/modules/mes/MesAdw300DataDao.xml
  26. 2 2
      src/main/resources/views/modules/mes/mesDeviceForm.html
  27. 2 2
      src/main/resources/views/modules/mes/mesEmployeeForm.html
  28. 176 0
      src/main/resources/views/modules/mes/mesEnergyScreen.html
  29. 15 2
      src/main/resources/views/modules/mes/mesScreen4.html
  30. 266 0
      tools/build_screen_data_doc.py

+ 2 - 0
db/mysql/add_mes_device_image_path.sql

@@ -0,0 +1,2 @@
+ALTER TABLE mes_device
+	ADD COLUMN image_path varchar(1000) DEFAULT NULL COMMENT '设备图片路径';

+ 32 - 0
db/mysql/add_mes_device_oee_screen.sql

@@ -0,0 +1,32 @@
+INSERT INTO mes_screen_config (
+	id,
+	screen_name,
+	screen_code,
+	screen_url,
+	screen_type,
+	play_duration,
+	sort_order,
+	is_enabled,
+	remarks,
+	create_by,
+	create_date,
+	update_by,
+	update_date
+)
+SELECT
+	'mes_device_oee_screen',
+	'设备综合效率管理',
+	'MES_DEVICE_OEE',
+	'/js/a/mes/mesDevice/oeeScreen',
+	'设备管理',
+	60,
+	50,
+	'1',
+	'基于设备状态记录计算开动率;当前数据不包含完整OEE所需的产量、良品、节拍字段',
+	'system',
+	NOW(),
+	'system',
+	NOW()
+WHERE NOT EXISTS (
+	SELECT 1 FROM mes_screen_config WHERE screen_code = 'MES_DEVICE_OEE'
+);

+ 28 - 0
db/mysql/add_mes_employee_menu.sql

@@ -0,0 +1,28 @@
+-- 员工信息管理菜单
+-- 挂载位置:系统管理 / 组织管理
+
+INSERT INTO `js_sys_menu` (
+  `menu_code`, `parent_code`, `parent_codes`, `tree_sort`, `tree_sorts`, `tree_leaf`, `tree_level`,
+  `tree_names`, `menu_name`, `menu_type`, `menu_href`, `menu_target`, `menu_icon`, `menu_color`,
+  `menu_title`, `permission`, `weight`, `is_show`, `sys_code`, `module_codes`, `component`, `params`,
+  `status`, `create_by`, `create_date`, `update_by`, `update_date`, `remarks`
+) VALUES (
+  'mesEmployee', '1582259826871238656', '0,1582259822626603008,1582259826871238656,',
+  45, '0000009000,0000000300,0000000045,', '1', 3,
+  '系统管理/组织管理/员工信息管理', '员工信息管理', '1', '/mes/mesEmployee/list', '',
+  'icon-people', '', '', 'mes:mesEmployee:view', 45, '1', 'default', 'core', NULL, NULL,
+  '0', 'system', NOW(), 'system', NOW(), ''
+);
+
+INSERT INTO `js_sys_menu` (
+  `menu_code`, `parent_code`, `parent_codes`, `tree_sort`, `tree_sorts`, `tree_leaf`, `tree_level`,
+  `tree_names`, `menu_name`, `menu_type`, `menu_href`, `menu_target`, `menu_icon`, `menu_color`,
+  `menu_title`, `permission`, `weight`, `is_show`, `sys_code`, `module_codes`, `component`, `params`,
+  `status`, `create_by`, `create_date`, `update_by`, `update_date`, `remarks`
+) VALUES (
+  'mesEmployeeEdit', 'mesEmployee', '0,1582259822626603008,1582259826871238656,mesEmployee,',
+  40, '0000009000,0000000300,0000000045,0000000040,', '1', 4,
+  '系统管理/组织管理/员工信息管理/编辑', '编辑', '2', '', '',
+  '', '', '', 'mes:mesEmployee:edit', 40, '1', 'default', 'core', NULL, NULL,
+  '0', 'system', NOW(), 'system', NOW(), ''
+);

+ 32 - 0
db/mysql/add_mes_material_screen.sql

@@ -0,0 +1,32 @@
+INSERT INTO mes_screen_config (
+	id,
+	screen_name,
+	screen_code,
+	screen_url,
+	screen_type,
+	play_duration,
+	sort_order,
+	is_enabled,
+	remarks,
+	create_by,
+	create_date,
+	update_by,
+	update_date
+)
+SELECT
+	'mes_material_screen',
+	'物料管理看板',
+	'MES_MATERIAL_SCREEN',
+	'/js/a/mes/mesMaterial/materialScreen',
+	'物料管理',
+	60,
+	70,
+	'1',
+	'基于物料、库存、报检、来料检查、入库、出库、领料、不良、退货、报废记录展示物料管理状态',
+	'system',
+	NOW(),
+	'system',
+	NOW()
+WHERE NOT EXISTS (
+	SELECT 1 FROM mes_screen_config WHERE screen_code = 'MES_MATERIAL_SCREEN'
+);

+ 32 - 0
db/mysql/add_mes_quality_screen.sql

@@ -0,0 +1,32 @@
+INSERT INTO mes_screen_config (
+	id,
+	screen_name,
+	screen_code,
+	screen_url,
+	screen_type,
+	play_duration,
+	sort_order,
+	is_enabled,
+	remarks,
+	create_by,
+	create_date,
+	update_by,
+	update_date
+)
+SELECT
+	'mes_quality_screen',
+	'质量管理看板',
+	'MES_QUALITY_SCREEN',
+	'/js/a/mes/mesProduct/qualityScreen',
+	'质量管理',
+	60,
+	60,
+	'1',
+	'基于过程检查、首件检查、NG、返修、报废记录展示最近7天生产过程质量状态,不包含来料质量数据',
+	'system',
+	NOW(),
+	'system',
+	NOW()
+WHERE NOT EXISTS (
+	SELECT 1 FROM mes_screen_config WHERE screen_code = 'MES_QUALITY_SCREEN'
+);

+ 20 - 0
db/mysql/mes_adw300_data.sql

@@ -0,0 +1,20 @@
+CREATE TABLE IF NOT EXISTS `mes_adw300_data` (
+  `id` varchar(64) NOT NULL COMMENT '编号',
+  `status` char(1) DEFAULT '0' COMMENT '状态',
+  `device_sn` varchar(64) DEFAULT NULL COMMENT '设备编号',
+  `report_time` datetime DEFAULT NULL COMMENT '设备上报时间',
+  `topic` varchar(255) DEFAULT NULL COMMENT 'MQTT主题',
+  `point_id` int DEFAULT NULL COMMENT '点位ID',
+  `point_name` varchar(100) DEFAULT NULL COMMENT '点位名称',
+  `point_value` varchar(255) DEFAULT NULL COMMENT '点位值',
+  `unit` varchar(32) DEFAULT NULL COMMENT '单位',
+  `raw_payload` longtext COMMENT '原始报文',
+  `create_by` varchar(64) DEFAULT NULL COMMENT '创建者',
+  `create_date` datetime DEFAULT NULL COMMENT '创建时间',
+  `update_by` varchar(64) DEFAULT NULL COMMENT '更新者',
+  `update_date` datetime DEFAULT NULL COMMENT '更新时间',
+  `remarks` varchar(500) DEFAULT NULL COMMENT '备注信息',
+  PRIMARY KEY (`id`),
+  KEY `idx_mes_adw300_device_time` (`device_sn`, `report_time`),
+  KEY `idx_mes_adw300_point` (`point_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='ADW300电表MQTT数据表';

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 13411 - 0
db/mysql/mes_cloud.sql


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 2852 - 717
mes_cloud.sql


BIN
output/factory-layout-renamed.png


BIN
output/imagegen/factory-layout-labels-edited-v2.png


BIN
output/imagegen/factory-layout-labels-edited.png


+ 5 - 0
pom.xml

@@ -206,6 +206,11 @@
 			<version>1.2.9.7</version>
 		</dependency>
 		<dependency>
+			<groupId>org.eclipse.paho</groupId>
+			<artifactId>org.eclipse.paho.client.mqttv3</artifactId>
+			<version>1.2.5</version>
+		</dependency>
+		<dependency>
 			<groupId>com.fazecast</groupId>
 			<artifactId>jSerialComm</artifactId>
 			<version>2.6.2</version>

+ 27 - 0
src/main/java/com/jeesite/modules/mes/dao/MesAdw300DataDao.java

@@ -0,0 +1,27 @@
+package com.jeesite.modules.mes.dao;
+
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+import com.jeesite.common.dao.CrudDao;
+import com.jeesite.common.mybatis.annotation.MyBatisDao;
+import com.jeesite.modules.mes.entity.MesAdw300Data;
+import org.apache.ibatis.annotations.Param;
+
+/**
+ * ADW300电表MQTT数据DAO接口
+ * @author mes
+ * @version 2026-06-12
+ */
+@MyBatisDao
+public interface MesAdw300DataDao extends CrudDao<MesAdw300Data> {
+
+	List<String> findDeviceSnList();
+
+	List<Map<String, Object>> findLatestSnapshot();
+
+	List<Map<String, Object>> findTodayEnergy(@Param("startTime") Date startTime, @Param("endTime") Date endTime);
+
+	List<Map<String, Object>> findHourlyEnergy(@Param("startTime") Date startTime, @Param("endTime") Date endTime);
+}

+ 141 - 0
src/main/java/com/jeesite/modules/mes/entity/MesAdw300Data.java

@@ -0,0 +1,141 @@
+package com.jeesite.modules.mes.entity;
+
+import java.util.Date;
+
+import javax.validation.constraints.Size;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.jeesite.common.entity.DataEntity;
+import com.jeesite.common.mybatis.annotation.Column;
+import com.jeesite.common.mybatis.annotation.Table;
+import com.jeesite.common.mybatis.mapper.query.QueryType;
+
+/**
+ * ADW300电表MQTT数据Entity
+ * @author mes
+ * @version 2026-06-12
+ */
+@Table(name="mes_adw300_data", alias="a", label="ADW300电表MQTT数据信息", columns={
+		@Column(name="id", attrName="id", label="id", isPK=true),
+		@Column(name="status", attrName="status", label="状态", isUpdate=false),
+		@Column(name="device_sn", attrName="deviceSn", label="设备编号", queryType=QueryType.LIKE),
+		@Column(name="report_time", attrName="reportTime", label="上报时间", isUpdateForce=true),
+		@Column(name="topic", attrName="topic", label="MQTT主题", queryType=QueryType.LIKE),
+		@Column(name="point_id", attrName="pointId", label="点位ID"),
+		@Column(name="point_name", attrName="pointName", label="点位名称", queryType=QueryType.LIKE),
+		@Column(name="point_value", attrName="pointValue", label="点位值"),
+		@Column(name="unit", attrName="unit", label="单位"),
+		@Column(name="raw_payload", attrName="rawPayload", label="原始报文", isQuery=false),
+		@Column(name="create_by", attrName="createBy", label="create_by", isUpdate=false, isQuery=false),
+		@Column(name="create_date", attrName="createDate", label="创建时间", isUpdate=false, isUpdateForce=true),
+		@Column(name="update_by", attrName="updateBy", label="update_by", isQuery=false),
+		@Column(name="update_date", attrName="updateDate", label="更新时间", isQuery=false, isUpdateForce=true),
+		@Column(name="remarks", attrName="remarks", label="备注", queryType=QueryType.LIKE),
+	}, orderBy="a.report_time DESC, a.point_id ASC"
+)
+public class MesAdw300Data extends DataEntity<MesAdw300Data> {
+
+	private static final long serialVersionUID = 1L;
+	private String deviceSn;
+	private Date reportTime;
+	private String topic;
+	private Integer pointId;
+	private String pointName;
+	private String pointValue;
+	private String unit;
+	private String rawPayload;
+
+	public MesAdw300Data() {
+		this(null);
+	}
+
+	public MesAdw300Data(String id) {
+		super(id);
+	}
+
+	@Size(min=0, max=64, message="设备编号长度不能超过 64 个字符")
+	public String getDeviceSn() {
+		return deviceSn;
+	}
+
+	public void setDeviceSn(String deviceSn) {
+		this.deviceSn = deviceSn;
+	}
+
+	@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+	public Date getReportTime() {
+		return reportTime;
+	}
+
+	public void setReportTime(Date reportTime) {
+		this.reportTime = reportTime;
+	}
+
+	@Size(min=0, max=255, message="MQTT主题长度不能超过 255 个字符")
+	public String getTopic() {
+		return topic;
+	}
+
+	public void setTopic(String topic) {
+		this.topic = topic;
+	}
+
+	public Integer getPointId() {
+		return pointId;
+	}
+
+	public void setPointId(Integer pointId) {
+		this.pointId = pointId;
+	}
+
+	@Size(min=0, max=100, message="点位名称长度不能超过 100 个字符")
+	public String getPointName() {
+		return pointName;
+	}
+
+	public void setPointName(String pointName) {
+		this.pointName = pointName;
+	}
+
+	@Size(min=0, max=255, message="点位值长度不能超过 255 个字符")
+	public String getPointValue() {
+		return pointValue;
+	}
+
+	public void setPointValue(String pointValue) {
+		this.pointValue = pointValue;
+	}
+
+	@Size(min=0, max=32, message="单位长度不能超过 32 个字符")
+	public String getUnit() {
+		return unit;
+	}
+
+	public void setUnit(String unit) {
+		this.unit = unit;
+	}
+
+	public String getRawPayload() {
+		return rawPayload;
+	}
+
+	public void setRawPayload(String rawPayload) {
+		this.rawPayload = rawPayload;
+	}
+
+	public Date getReportTime_gte() {
+		return sqlMap.getWhere().getValue("report_time", QueryType.GTE);
+	}
+
+	public void setReportTime_gte(Date reportTime) {
+		sqlMap.getWhere().and("report_time", QueryType.GTE, reportTime);
+	}
+
+	public Date getReportTime_lte() {
+		return sqlMap.getWhere().getValue("report_time", QueryType.LTE);
+	}
+
+	public void setReportTime_lte(Date reportTime) {
+		sqlMap.getWhere().and("report_time", QueryType.LTE, reportTime);
+	}
+}

+ 54 - 0
src/main/java/com/jeesite/modules/mes/mqtt/Adw300Message.java

@@ -0,0 +1,54 @@
+package com.jeesite.modules.mes.mqtt;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+public class Adw300Message {
+
+	private String topic;
+	private String deviceNo;
+	private Date reportTime;
+	private String payload;
+	private List<Adw300Point> points = new ArrayList<>();
+
+	public String getTopic() {
+		return topic;
+	}
+
+	public void setTopic(String topic) {
+		this.topic = topic;
+	}
+
+	public String getDeviceNo() {
+		return deviceNo;
+	}
+
+	public void setDeviceNo(String deviceNo) {
+		this.deviceNo = deviceNo;
+	}
+
+	public Date getReportTime() {
+		return reportTime;
+	}
+
+	public void setReportTime(Date reportTime) {
+		this.reportTime = reportTime;
+	}
+
+	public String getPayload() {
+		return payload;
+	}
+
+	public void setPayload(String payload) {
+		this.payload = payload;
+	}
+
+	public List<Adw300Point> getPoints() {
+		return points;
+	}
+
+	public void setPoints(List<Adw300Point> points) {
+		this.points = points;
+	}
+}

+ 31 - 0
src/main/java/com/jeesite/modules/mes/mqtt/Adw300MqttMessageHandler.java

@@ -0,0 +1,31 @@
+package com.jeesite.modules.mes.mqtt;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import com.jeesite.modules.mes.service.MesAdw300DataService;
+
+@Component
+public class Adw300MqttMessageHandler {
+
+	private static final Logger logger = LoggerFactory.getLogger(Adw300MqttMessageHandler.class);
+
+	@Autowired
+	private Adw300MqttProperties properties;
+	@Autowired
+	private Adw300PayloadParser parser;
+	@Autowired
+	private MesAdw300DataService mesAdw300DataService;
+
+	public void handle(String topic, String payload) {
+		Adw300Message message = parser.parse(topic, payload);
+		logger.info("收到ADW300数据 topic={}, deviceNo={}, pointCount={}",
+				message.getTopic(), message.getDeviceNo(), message.getPoints().size());
+
+		if (properties.getStore().isEnabled()) {
+			mesAdw300DataService.saveMessage(message);
+		}
+	}
+}

+ 94 - 0
src/main/java/com/jeesite/modules/mes/mqtt/Adw300MqttProperties.java

@@ -0,0 +1,94 @@
+package com.jeesite.modules.mes.mqtt;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+@Component
+@ConfigurationProperties(prefix = "adw300.mqtt")
+public class Adw300MqttProperties {
+
+	private boolean enabled = false;
+	private String broker = "tcp://127.0.0.1:1883";
+	private String clientId = "mescloud-adw300-reader";
+	private String username;
+	private String password;
+	private String topic = "/gw/acrelHW/ADW300/data/#";
+	private int qos = 1;
+	private Store store = new Store();
+
+	public boolean isEnabled() {
+		return enabled;
+	}
+
+	public void setEnabled(boolean enabled) {
+		this.enabled = enabled;
+	}
+
+	public String getBroker() {
+		return broker;
+	}
+
+	public void setBroker(String broker) {
+		this.broker = broker;
+	}
+
+	public String getClientId() {
+		return clientId;
+	}
+
+	public void setClientId(String clientId) {
+		this.clientId = clientId;
+	}
+
+	public String getUsername() {
+		return username;
+	}
+
+	public void setUsername(String username) {
+		this.username = username;
+	}
+
+	public String getPassword() {
+		return password;
+	}
+
+	public void setPassword(String password) {
+		this.password = password;
+	}
+
+	public String getTopic() {
+		return topic;
+	}
+
+	public void setTopic(String topic) {
+		this.topic = topic;
+	}
+
+	public int getQos() {
+		return qos;
+	}
+
+	public void setQos(int qos) {
+		this.qos = qos;
+	}
+
+	public Store getStore() {
+		return store;
+	}
+
+	public void setStore(Store store) {
+		this.store = store;
+	}
+
+	public static class Store {
+		private boolean enabled = false;
+
+		public boolean isEnabled() {
+			return enabled;
+		}
+
+		public void setEnabled(boolean enabled) {
+			this.enabled = enabled;
+		}
+	}
+}

+ 118 - 0
src/main/java/com/jeesite/modules/mes/mqtt/Adw300MqttSubscriber.java

@@ -0,0 +1,118 @@
+package com.jeesite.modules.mes.mqtt;
+
+import java.nio.charset.StandardCharsets;
+
+import org.apache.commons.lang3.StringUtils;
+import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken;
+import org.eclipse.paho.client.mqttv3.MqttCallbackExtended;
+import org.eclipse.paho.client.mqttv3.MqttClient;
+import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
+import org.eclipse.paho.client.mqttv3.MqttException;
+import org.eclipse.paho.client.mqttv3.MqttMessage;
+import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.DisposableBean;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.ApplicationArguments;
+import org.springframework.boot.ApplicationRunner;
+import org.springframework.stereotype.Component;
+
+@Component
+public class Adw300MqttSubscriber implements ApplicationRunner, DisposableBean {
+
+	private static final Logger logger = LoggerFactory.getLogger(Adw300MqttSubscriber.class);
+
+	@Autowired
+	private Adw300MqttProperties properties;
+	@Autowired
+	private Adw300MqttMessageHandler messageHandler;
+
+	private MqttClient client;
+
+	@Override
+	public void run(ApplicationArguments args) throws Exception {
+		if (!properties.isEnabled()) {
+			logger.info("ADW300 MQTT订阅未启用");
+			return;
+		}
+
+		Thread thread = new Thread(this::connectWithRetry, "adw300-mqtt-subscriber");
+		thread.setDaemon(true);
+		thread.start();
+	}
+
+	private void connectWithRetry() {
+		while (true) {
+			try {
+				connect();
+				return;
+			} catch (Exception e) {
+				logger.warn("ADW300 MQTT连接失败,10秒后重试 broker={}", properties.getBroker(), e);
+				try {
+					Thread.sleep(10000L);
+				} catch (InterruptedException interruptedException) {
+					Thread.currentThread().interrupt();
+					return;
+				}
+			}
+		}
+	}
+
+	private void connect() throws MqttException {
+		client = new MqttClient(properties.getBroker(), properties.getClientId(), new MemoryPersistence());
+		client.setCallback(new MqttCallbackExtended() {
+			@Override
+			public void connectComplete(boolean reconnect, String serverURI) {
+				try {
+					client.subscribe(properties.getTopic(), properties.getQos());
+					logger.info("ADW300 MQTT订阅成功 broker={}, topic={}, qos={}",
+							serverURI, properties.getTopic(), properties.getQos());
+				} catch (MqttException e) {
+					logger.error("ADW300 MQTT订阅失败", e);
+				}
+			}
+
+			@Override
+			public void connectionLost(Throwable cause) {
+				logger.warn("ADW300 MQTT连接断开,将自动重连", cause);
+			}
+
+			@Override
+			public void messageArrived(String topic, MqttMessage message) {
+				String payload = new String(message.getPayload(), StandardCharsets.UTF_8);
+				try {
+					messageHandler.handle(topic, payload);
+				} catch (Exception e) {
+					logger.error("ADW300 MQTT报文处理失败 topic={}, payload={}", topic, payload, e);
+				}
+			}
+
+			@Override
+			public void deliveryComplete(IMqttDeliveryToken token) {
+				// Subscriber only.
+			}
+		});
+
+		MqttConnectOptions options = new MqttConnectOptions();
+		options.setAutomaticReconnect(true);
+		options.setCleanSession(true);
+		if (StringUtils.isNotBlank(properties.getUsername())) {
+			options.setUserName(properties.getUsername());
+		}
+		if (StringUtils.isNotBlank(properties.getPassword())) {
+			options.setPassword(properties.getPassword().toCharArray());
+		}
+
+		logger.info("正在连接ADW300 MQTT broker={}", properties.getBroker());
+		client.connect(options);
+	}
+
+	@Override
+	public void destroy() throws Exception {
+		if (client != null && client.isConnected()) {
+			client.disconnect();
+			client.close();
+		}
+	}
+}

+ 80 - 0
src/main/java/com/jeesite/modules/mes/mqtt/Adw300PayloadParser.java

@@ -0,0 +1,80 @@
+package com.jeesite.modules.mes.mqtt;
+
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import org.springframework.stereotype.Component;
+
+@Component
+public class Adw300PayloadParser {
+
+	private static final Map<Integer, String[]> POINT_META = new LinkedHashMap<>();
+
+	static {
+		POINT_META.put(0, new String[]{"设备编号", ""});
+		POINT_META.put(1, new String[]{"A相电压", "V"});
+		POINT_META.put(2, new String[]{"B相电压", "V"});
+		POINT_META.put(3, new String[]{"C相电压", "V"});
+		POINT_META.put(4, new String[]{"AB线电压", "V"});
+		POINT_META.put(5, new String[]{"BC线电压", "V"});
+		POINT_META.put(6, new String[]{"CA线电压", "V"});
+		POINT_META.put(7, new String[]{"A相电流", "A"});
+		POINT_META.put(8, new String[]{"B相电流", "A"});
+		POINT_META.put(9, new String[]{"C相电流", "A"});
+		POINT_META.put(10, new String[]{"A相有功功率", "kW"});
+		POINT_META.put(11, new String[]{"B相有功功率", "kW"});
+		POINT_META.put(12, new String[]{"C相有功功率", "kW"});
+		POINT_META.put(13, new String[]{"总有功功率", "kW"});
+		POINT_META.put(26, new String[]{"频率", "Hz"});
+		POINT_META.put(33, new String[]{"物联网卡号", ""});
+	}
+
+	public Adw300Message parse(String topic, String payload) {
+		JSONObject root = JSON.parseObject(payload);
+		JSONArray dataArray = root.getJSONArray("data");
+		if (dataArray == null || dataArray.isEmpty()) {
+			throw new IllegalArgumentException("ADW300报文缺少data数组");
+		}
+
+		JSONObject data = dataArray.getJSONObject(0);
+		Adw300Message message = new Adw300Message();
+		message.setTopic(topic);
+		message.setPayload(payload);
+		Long tp = data.getLong("tp");
+		if (tp != null) {
+			message.setReportTime(new Date(tp));
+		}
+
+		JSONArray pointArray = data.getJSONArray("point");
+		if (pointArray == null) {
+			throw new IllegalArgumentException("ADW300报文缺少point数组");
+		}
+
+		for (int i = 0; i < pointArray.size(); i++) {
+			JSONObject item = pointArray.getJSONObject(i);
+			Integer id = item.getInteger("id");
+			if (id == null) {
+				continue;
+			}
+
+			Adw300Point point = new Adw300Point();
+			point.setId(id);
+			point.setValue(item.get("val"));
+			String[] meta = POINT_META.get(id);
+			if (meta != null) {
+				point.setName(meta[0]);
+				point.setUnit(meta[1]);
+			}
+			message.getPoints().add(point);
+
+			if (id == 0 && item.get("val") != null) {
+				message.setDeviceNo(String.valueOf(item.get("val")));
+			}
+		}
+		return message;
+	}
+}

+ 41 - 0
src/main/java/com/jeesite/modules/mes/mqtt/Adw300Point.java

@@ -0,0 +1,41 @@
+package com.jeesite.modules.mes.mqtt;
+
+public class Adw300Point {
+
+	private Integer id;
+	private Object value;
+	private String name;
+	private String unit;
+
+	public Integer getId() {
+		return id;
+	}
+
+	public void setId(Integer id) {
+		this.id = id;
+	}
+
+	public Object getValue() {
+		return value;
+	}
+
+	public void setValue(Object value) {
+		this.value = value;
+	}
+
+	public String getName() {
+		return name;
+	}
+
+	public void setName(String name) {
+		this.name = name;
+	}
+
+	public String getUnit() {
+		return unit;
+	}
+
+	public void setUnit(String unit) {
+		this.unit = unit;
+	}
+}

+ 261 - 0
src/main/java/com/jeesite/modules/mes/service/MesAdw300DataService.java

@@ -0,0 +1,261 @@
+package com.jeesite.modules.mes.service;
+
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import com.jeesite.common.collect.ListUtils;
+import com.jeesite.common.collect.MapUtils;
+import com.jeesite.common.entity.Page;
+import com.jeesite.common.service.CrudService;
+import com.jeesite.modules.mes.dao.MesAdw300DataDao;
+import com.jeesite.modules.mes.entity.MesAdw300Data;
+import com.jeesite.modules.mes.mqtt.Adw300Message;
+import com.jeesite.modules.mes.mqtt.Adw300Point;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * ADW300电表MQTT数据Service
+ * @author mes
+ * @version 2026-06-12
+ */
+@Service
+public class MesAdw300DataService extends CrudService<MesAdw300DataDao, MesAdw300Data> {
+
+	@Override
+	public MesAdw300Data get(MesAdw300Data mesAdw300Data) {
+		return super.get(mesAdw300Data);
+	}
+
+	@Override
+	public Page<MesAdw300Data> findPage(MesAdw300Data mesAdw300Data) {
+		return super.findPage(mesAdw300Data);
+	}
+
+	@Override
+	public List<MesAdw300Data> findList(MesAdw300Data mesAdw300Data) {
+		return super.findList(mesAdw300Data);
+	}
+
+	public Map<String, Object> getEnergyScreenData() {
+		Date now = new Date();
+		Date todayStart = getTodayStart(now);
+		Date tomorrowStart = new Date(todayStart.getTime() + 24L * 60L * 60L * 1000L);
+
+		List<String> deviceSnList = dao.findDeviceSnList();
+		List<Map<String, Object>> latestRows = dao.findLatestSnapshot();
+		Map<String, Map<Integer, Map<String, Object>>> latestMap = new LinkedHashMap<>();
+		for (String deviceSn : deviceSnList) {
+			latestMap.put(deviceSn, new LinkedHashMap<>());
+		}
+		for (Map<String, Object> row : latestRows) {
+			String deviceSn = getString(row.get("deviceSn"));
+			Integer pointId = getInteger(row.get("pointId"));
+			if (deviceSn == null || pointId == null) {
+				continue;
+			}
+			if (!latestMap.containsKey(deviceSn)) {
+				latestMap.put(deviceSn, new LinkedHashMap<>());
+			}
+			latestMap.get(deviceSn).put(pointId, row);
+		}
+
+		Map<String, Double> todayEnergyMap = mapNumberByDevice(dao.findTodayEnergy(todayStart, tomorrowStart), "energy");
+		List<Map<String, Object>> devices = ListUtils.newArrayList();
+		double totalPower = 0D;
+		double totalEnergy = 0D;
+		int onlineCount = 0;
+
+		for (Map.Entry<String, Map<Integer, Map<String, Object>>> entry : latestMap.entrySet()) {
+			String deviceSn = entry.getKey();
+			Map<Integer, Map<String, Object>> points = entry.getValue();
+			Map<String, Object> device = MapUtils.newHashMap();
+			double power = getPointDouble(points, 13);
+			double todayEnergy = todayEnergyMap.getOrDefault(deviceSn, 0D);
+			Date reportTime = getDate(points);
+			boolean online = reportTime != null && now.getTime() - reportTime.getTime() <= 5L * 60L * 1000L;
+			if (online) {
+				onlineCount++;
+			}
+			totalPower += power;
+			totalEnergy += todayEnergy;
+
+			device.put("deviceSn", deviceSn);
+			device.put("online", online);
+			device.put("reportTime", reportTime);
+			device.put("power", round(power));
+			device.put("todayEnergy", round(todayEnergy));
+			device.put("totalEnergy", round(getPointDouble(points, 28)));
+			device.put("ua", round(getPointDouble(points, 1)));
+			device.put("ub", round(getPointDouble(points, 2)));
+			device.put("uc", round(getPointDouble(points, 3)));
+			device.put("ia", round(getPointDouble(points, 7)));
+			device.put("ib", round(getPointDouble(points, 8)));
+			device.put("ic", round(getPointDouble(points, 9)));
+			devices.add(device);
+		}
+
+		Map<String, Object> data = MapUtils.newHashMap();
+		data.put("devices", devices);
+		data.put("deviceCount", devices.size());
+		data.put("onlineCount", onlineCount);
+		data.put("totalPower", round(totalPower));
+		data.put("todayEnergy", round(totalEnergy));
+		data.put("hours", buildHours());
+		data.put("hourlySeries", buildHourlySeries(deviceSnList, dao.findHourlyEnergy(todayStart, tomorrowStart)));
+		data.put("refreshTime", now);
+		return data;
+	}
+
+	@Override
+	@Transactional
+	public void save(MesAdw300Data mesAdw300Data) {
+		super.save(mesAdw300Data);
+	}
+
+	@Transactional
+	public void saveMessage(Adw300Message message) {
+		Date reportTime = message.getReportTime() == null ? new Date() : message.getReportTime();
+		for (Adw300Point point : message.getPoints()) {
+			MesAdw300Data data = new MesAdw300Data();
+			data.setDeviceSn(message.getDeviceNo());
+			data.setReportTime(reportTime);
+			data.setTopic(message.getTopic());
+			data.setPointId(point.getId());
+			data.setPointName(point.getName());
+			data.setPointValue(point.getValue() == null ? null : String.valueOf(point.getValue()));
+			data.setUnit(point.getUnit());
+			data.setRawPayload(message.getPayload());
+			this.save(data);
+		}
+	}
+
+	@Override
+	@Transactional
+	public void updateStatus(MesAdw300Data mesAdw300Data) {
+		super.updateStatus(mesAdw300Data);
+	}
+
+	@Override
+	@Transactional
+	public void delete(MesAdw300Data mesAdw300Data) {
+		super.delete(mesAdw300Data);
+	}
+
+	private Date getTodayStart(Date now) {
+		java.util.Calendar calendar = java.util.Calendar.getInstance();
+		calendar.setTime(now);
+		calendar.set(java.util.Calendar.HOUR_OF_DAY, 0);
+		calendar.set(java.util.Calendar.MINUTE, 0);
+		calendar.set(java.util.Calendar.SECOND, 0);
+		calendar.set(java.util.Calendar.MILLISECOND, 0);
+		return calendar.getTime();
+	}
+
+	private List<String> buildHours() {
+		List<String> hours = ListUtils.newArrayList();
+		for (int i = 0; i < 24; i++) {
+			hours.add(String.format("%02d:00", i));
+		}
+		return hours;
+	}
+
+	private List<Map<String, Object>> buildHourlySeries(List<String> deviceSnList, List<Map<String, Object>> rows) {
+		Map<String, Map<String, Double>> dataMap = new LinkedHashMap<>();
+		for (String deviceSn : deviceSnList) {
+			dataMap.put(deviceSn, new LinkedHashMap<>());
+		}
+		for (Map<String, Object> row : rows) {
+			String deviceSn = getString(row.get("deviceSn"));
+			String hourLabel = getString(row.get("hourLabel"));
+			if (deviceSn == null || hourLabel == null) {
+				continue;
+			}
+			if (!dataMap.containsKey(deviceSn)) {
+				dataMap.put(deviceSn, new LinkedHashMap<>());
+			}
+			dataMap.get(deviceSn).put(hourLabel, getDouble(row.get("energy")));
+		}
+
+		List<String> hours = buildHours();
+		List<Map<String, Object>> series = ListUtils.newArrayList();
+		for (Map.Entry<String, Map<String, Double>> entry : dataMap.entrySet()) {
+			Map<String, Object> item = MapUtils.newHashMap();
+			List<Double> values = ListUtils.newArrayList();
+			for (String hour : hours) {
+				values.add(round(entry.getValue().getOrDefault(hour, 0D)));
+			}
+			item.put("name", entry.getKey());
+			item.put("data", values);
+			series.add(item);
+		}
+		return series;
+	}
+
+	private Map<String, Double> mapNumberByDevice(List<Map<String, Object>> rows, String key) {
+		Map<String, Double> result = new LinkedHashMap<>();
+		for (Map<String, Object> row : rows) {
+			String deviceSn = getString(row.get("deviceSn"));
+			if (deviceSn != null) {
+				result.put(deviceSn, getDouble(row.get(key)));
+			}
+		}
+		return result;
+	}
+
+	private Date getDate(Map<Integer, Map<String, Object>> points) {
+		for (Map<String, Object> point : points.values()) {
+			Object value = point.get("reportTime");
+			if (value instanceof Date) {
+				return (Date) value;
+			}
+		}
+		return null;
+	}
+
+	private double getPointDouble(Map<Integer, Map<String, Object>> points, int pointId) {
+		Map<String, Object> point = points.get(pointId);
+		if (point == null) {
+			return 0D;
+		}
+		return getDouble(point.get("pointValue"));
+	}
+
+	private String getString(Object value) {
+		return value == null ? null : String.valueOf(value);
+	}
+
+	private Integer getInteger(Object value) {
+		if (value == null) {
+			return null;
+		}
+		if (value instanceof Number) {
+			return ((Number) value).intValue();
+		}
+		try {
+			return Integer.valueOf(String.valueOf(value));
+		} catch (Exception e) {
+			return null;
+		}
+	}
+
+	private double getDouble(Object value) {
+		if (value == null) {
+			return 0D;
+		}
+		if (value instanceof Number) {
+			return ((Number) value).doubleValue();
+		}
+		try {
+			return Double.parseDouble(String.valueOf(value));
+		} catch (Exception e) {
+			return 0D;
+		}
+	}
+
+	private double round(double value) {
+		return Math.round(value * 1000D) / 1000D;
+	}
+}

+ 1 - 1
src/main/java/com/jeesite/modules/mes/web/MesDeviceController.java

@@ -220,7 +220,7 @@ public class MesDeviceController extends BaseController {
 		MesDevice mesDevice1 = new MesDevice();
 		mesDevice1.setDeviceCode(mesDevice.getDeviceCode());
 		MesDevice info = mesDeviceService.getByEntity(mesDevice1);
-		if (info!=null){
+		if (info != null && !StringUtils.equals(info.getTreeCode(), mesDevice.getTreeCode())){
 			return renderResult(Global.FALSE, text("保存失败,设备编码已存在!"));
 		}
 		mesDeviceService.save(mesDevice);

+ 16 - 0
src/main/java/com/jeesite/modules/mes/web/MesProductController.java

@@ -88,6 +88,8 @@ public class MesProductController extends BaseController {
 	@Autowired
 	private MesProductCheckService mesProductCheckService;
 	@Autowired
+	private MesAdw300DataService mesAdw300DataService;
+	@Autowired
 	private MesProductCheckFirstService mesProductCheckFirstService;
 	@Autowired
 	private MesNgService mesNgService;
@@ -314,6 +316,20 @@ public class MesProductController extends BaseController {
 		return "modules/mes/mesScreen4";
 	}
 
+	@RequestMapping(value = "energyScreen")
+	public String energyScreen(Model model) {
+		return "modules/mes/mesEnergyScreen";
+	}
+
+	@RequestMapping(value = "energyScreenData")
+	@ResponseBody
+	public CommonResp<Map<String, Object>> energyScreenData() {
+		CommonResp<Map<String, Object>> resp = new CommonResp<>();
+		resp.setData(mesAdw300DataService.getEnergyScreenData());
+		resp.setResult(Global.TRUE);
+		return resp;
+	}
+
 	@RequestMapping(value = "screen2")
 	public String screen3(Model model) {
 		return "modules/mes/mesScreen2";

+ 13 - 0
src/main/resources/config/application.yml

@@ -19,6 +19,19 @@ apiMode: false
 tess4j:
   datapath: D:/tessdata
 
+# ADW300-WiFi MQTT接收配置。先保持store.enabled=false,确认能收到报文后再建表并打开入库。
+adw300:
+  mqtt:
+    enabled: true
+    broker: tcp://127.0.0.1:1883
+    clientId: mescloud-adw300-reader
+    username:
+    password:
+    topic: /gw/acrelHW/ADW300/data/#
+    qos: 1
+    store:
+      enabled: true
+
 #======================================#
 #========== Server settings ===========#
 #======================================#

+ 74 - 0
src/main/resources/mappings/modules/mes/MesAdw300DataDao.xml

@@ -0,0 +1,74 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.jeesite.modules.mes.dao.MesAdw300DataDao">
+
+	<select id="findDeviceSnList" resultType="String">
+		SELECT DISTINCT device_sn
+		FROM mes_adw300_data
+		WHERE status = '0'
+		  AND device_sn IS NOT NULL
+		  AND device_sn != ''
+		ORDER BY device_sn
+	</select>
+
+	<select id="findLatestSnapshot" resultType="java.util.Map">
+		SELECT
+			a.device_sn AS deviceSn,
+			a.report_time AS reportTime,
+			a.topic AS topic,
+			a.point_id AS pointId,
+			a.point_name AS pointName,
+			a.point_value AS pointValue,
+			a.unit AS unit
+		FROM mes_adw300_data a
+		INNER JOIN (
+			SELECT device_sn, MAX(report_time) AS report_time
+			FROM mes_adw300_data
+			WHERE status = '0'
+			  AND device_sn IS NOT NULL
+			  AND device_sn != ''
+			GROUP BY device_sn
+		) b ON a.device_sn = b.device_sn AND a.report_time = b.report_time
+		WHERE a.status = '0'
+		ORDER BY a.device_sn, a.point_id
+	</select>
+
+	<select id="findTodayEnergy" resultType="java.util.Map">
+		SELECT
+			device_sn AS deviceSn,
+			ROUND(MAX(CAST(point_value AS DECIMAL(18, 4))) - MIN(CAST(point_value AS DECIMAL(18, 4))), 3) AS energy
+		FROM mes_adw300_data
+		WHERE status = '0'
+		  AND point_id = 28
+		  AND report_time &gt;= #{startTime}
+		  AND report_time &lt; #{endTime}
+		  AND point_value REGEXP '^-?[0-9]+(\\.[0-9]+)?$'
+		GROUP BY device_sn
+	</select>
+
+	<select id="findHourlyEnergy" resultType="java.util.Map">
+		SELECT
+			device_sn AS deviceSn,
+			DATE_FORMAT(report_time, '%H:00') AS hourLabel,
+			ROUND(MAX(CAST(point_value AS DECIMAL(18, 4))) - MIN(CAST(point_value AS DECIMAL(18, 4))), 3) AS energy
+		FROM mes_adw300_data
+		WHERE status = '0'
+		  AND point_id = 28
+		  AND report_time &gt;= #{startTime}
+		  AND report_time &lt; #{endTime}
+		  AND point_value REGEXP '^-?[0-9]+(\\.[0-9]+)?$'
+		GROUP BY device_sn, DATE_FORMAT(report_time, '%H:00')
+		ORDER BY hourLabel, device_sn
+	</select>
+
+	<!-- 查询数据
+	<select id="findList" resultType="MesAdw300Data">
+		SELECT ${sqlMap.column.toSql()}
+		FROM ${sqlMap.table.toSql()}
+		<where>
+			${sqlMap.where.toSql()}
+		</where>
+		ORDER BY ${sqlMap.order.toSql()}
+	</select> -->
+
+</mapper>

+ 2 - 2
src/main/resources/views/modules/mes/mesDeviceForm.html

@@ -58,9 +58,9 @@
 					<div class="col-xs-6">
 						<div class="form-group">
 							<label class="control-label col-sm-4" title="">
-								<span class="required ">*</span> ${text('IP地址')}:<i class="fa icon-question hide"></i></label>
+								<span class="required hide">*</span> ${text('IP地址')}:<i class="fa icon-question hide"></i></label>
 							<div class="col-sm-8">
-								<#form:input path="ip" maxlength="255" class="form-control required"/>
+								<#form:input path="ip" maxlength="255" class="form-control"/>
 							</div>
 						</div>
 					</div>

+ 2 - 2
src/main/resources/views/modules/mes/mesEmployeeForm.html

@@ -24,7 +24,7 @@
 					</div>
 					<div class="col-xs-6">
 						<div class="form-group">
-							<label class="control-label col-sm-4">${text('员工工号')}:</label>
+							<label class="control-label col-sm-4"><span class="required">*</span> ${text('员工工号')}:</label>
 							<div class="col-sm-8">
 								<#form:input path="empNo" maxlength="100" class="form-control"/>
 							</div>
@@ -43,7 +43,7 @@
 					</div>
 					<div class="col-xs-6">
 						<div class="form-group">
-							<label class="control-label col-sm-4">${text('岗位')}:</label>
+							<label class="control-label col-sm-4"><span class="required">*</span> ${text('岗位')}:</label>
 							<div class="col-sm-8">
 								<#form:treeselect id="post" title="${text('岗位选择')}"
 									path="postCode" labelPath="postName"

+ 176 - 0
src/main/resources/views/modules/mes/mesEnergyScreen.html

@@ -0,0 +1,176 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>能耗监控大屏</title>
+    <style>
+        * { box-sizing: border-box; }
+        body {
+            margin: 0;
+            width: 100vw;
+            height: 100vh;
+            overflow: hidden;
+            color: #e9f7ff;
+            font-family: "Microsoft YaHei", Arial, sans-serif;
+            background: #07121f;
+        }
+        .screen {
+            width: 100vw;
+            height: 100vh;
+            padding: 18px 22px;
+            display: grid;
+            grid-template-rows: 74px 118px 310px 1fr;
+            gap: 14px;
+            background: linear-gradient(135deg, #07121f 0%, #102234 48%, #081522 100%);
+        }
+        .header {
+            display: flex;
+            align-items: center;
+            justify-content: space-between;
+            border-bottom: 1px solid rgba(108, 214, 255, .28);
+        }
+        .title { font-size: 30px; font-weight: 700; letter-spacing: 0; }
+        .time { text-align: right; color: #9fd8ff; font-size: 16px; line-height: 26px; }
+        .summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; }
+        .metric, .panel, .device-card {
+            border: 1px solid rgba(95, 201, 255, .28);
+            background: rgba(7, 25, 41, .76);
+            box-shadow: inset 0 0 22px rgba(49, 163, 220, .08);
+            border-radius: 6px;
+        }
+        .metric { padding: 18px 20px; display: flex; flex-direction: column; justify-content: center; }
+        .metric-label { color: #9fb6c7; font-size: 15px; }
+        .metric-value { margin-top: 8px; font-size: 34px; font-weight: 700; color: #44e0c2; }
+        .metric-unit { margin-left: 6px; font-size: 16px; color: #a8cfe7; }
+        .main { min-height: 0; }
+        .device-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; min-height: 0; }
+        .device-card { padding: 16px; min-height: 0; }
+        .device-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
+        .device-name { font-size: 20px; font-weight: 700; }
+        .badge { padding: 4px 10px; border-radius: 4px; font-size: 13px; background: rgba(68, 224, 194, .18); color: #44e0c2; }
+        .badge.offline { background: rgba(255, 103, 103, .14); color: #ff8686; }
+        .kv-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
+        .kv { padding: 10px; border-radius: 4px; background: rgba(255,255,255,.04); }
+        .kv span { display: block; color: #92a9bb; font-size: 13px; margin-bottom: 5px; }
+        .kv strong {
+            display: block;
+            width: 100%;
+            font-size: 22px;
+            color: #ffffff;
+            line-height: 1.25;
+            white-space: normal;
+            overflow-wrap: anywhere;
+        }
+        .kv strong.small { font-size: 17px; }
+        .chart-wrap { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; min-height: 0; }
+        .panel { min-height: 0; padding: 14px; display: flex; flex-direction: column; }
+        .panel-title { height: 30px; line-height: 30px; font-size: 18px; font-weight: 700; color: #79d9ff; }
+        .chart { flex: 1; min-height: 0; }
+    </style>
+</head>
+<body>
+<div class="screen">
+    <div class="header">
+        <div class="title">能源消耗监控大屏</div>
+        <div class="time"><div id="dateText">--</div><div id="timeText">--</div></div>
+    </div>
+    <div class="summary">
+        <div class="metric"><div class="metric-label" id="meter1PowerLabel">电表1 当前功率</div><div class="metric-value"><span id="meter1Power">0</span><span class="metric-unit">kW</span></div></div>
+        <div class="metric"><div class="metric-label" id="meter1EnergyLabel">电表1 今日用电</div><div class="metric-value"><span id="meter1Energy">0</span><span class="metric-unit">kWh</span></div></div>
+        <div class="metric"><div class="metric-label" id="meter2PowerLabel">电表2 当前功率</div><div class="metric-value"><span id="meter2Power">0</span><span class="metric-unit">kW</span></div></div>
+        <div class="metric"><div class="metric-label" id="meter2EnergyLabel">电表2 今日用电</div><div class="metric-value"><span id="meter2Energy">0</span><span class="metric-unit">kWh</span></div></div>
+    </div>
+    <div class="main">
+        <div class="device-list" id="deviceList"></div>
+    </div>
+    <div class="chart-wrap">
+        <div class="panel"><div class="panel-title">今日小时能耗趋势</div><div id="trendChart" class="chart"></div></div>
+        <div class="panel"><div class="panel-title">实时功率对比</div><div id="powerChart" class="chart"></div></div>
+    </div>
+</div>
+<script src="${ctxStatic}/jquery-1.11.3.min.js"></script>
+<script src="${ctxStatic}/echarts/4.2/echarts.min.js"></script>
+<script>
+    var trendChart = echarts.init(document.getElementById('trendChart'));
+    var powerChart = echarts.init(document.getElementById('powerChart'));
+
+    function fmt(value) {
+        var num = Number(value || 0);
+        return Number.isFinite(num) ? num.toFixed(2) : '0.00';
+    }
+    function updateClock() {
+        var now = new Date();
+        $('#dateText').text(now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0') + '-' + String(now.getDate()).padStart(2, '0'));
+        $('#timeText').text(String(now.getHours()).padStart(2, '0') + ':' + String(now.getMinutes()).padStart(2, '0') + ':' + String(now.getSeconds()).padStart(2, '0'));
+    }
+    function renderDevices(devices) {
+        var html = '';
+        devices.forEach(function (d, index) {
+            html += '<div class="device-card">' +
+                '<div class="device-head"><div class="device-name">电表 ' + (index + 1) + ':' + (d.deviceSn || '-') + '</div>' +
+                '<div class="badge ' + (d.online ? '' : 'offline') + '">' + (d.online ? '在线' : '离线') + '</div></div>' +
+                '<div class="kv-grid">' +
+                '<div class="kv"><span>当前功率</span><strong>' + fmt(d.power) + ' kW</strong></div>' +
+                '<div class="kv"><span>今日用电</span><strong>' + fmt(d.todayEnergy) + ' kWh</strong></div>' +
+                '<div class="kv"><span>总电能</span><strong>' + fmt(d.totalEnergy) + ' kWh</strong></div>' +
+                '<div class="kv"><span>A/B/C电压</span><strong class="small">' + fmt(d.ua) + ' / ' + fmt(d.ub) + ' / ' + fmt(d.uc) + ' V</strong></div>' +
+                '<div class="kv"><span>A/B/C电流</span><strong class="small">' + fmt(d.ia) + ' / ' + fmt(d.ib) + ' / ' + fmt(d.ic) + ' A</strong></div>' +
+                '<div class="kv"><span>上报时间</span><strong class="small">' + (d.reportTime || '-') + '</strong></div>' +
+                '</div></div>';
+        });
+        $('#deviceList').html(html || '<div class="device-card"><div class="device-name">暂无电表数据</div></div>');
+    }
+    function renderTopMeters(devices) {
+        for (var i = 0; i < 2; i++) {
+            var device = devices[i] || {};
+            var title = device.deviceSn ? device.deviceSn : ('电表' + (i + 1));
+            $('#meter' + (i + 1) + 'PowerLabel').text(title + ' 当前功率');
+            $('#meter' + (i + 1) + 'EnergyLabel').text(title + ' 今日用电');
+            $('#meter' + (i + 1) + 'Power').text(fmt(device.power));
+            $('#meter' + (i + 1) + 'Energy').text(fmt(device.todayEnergy));
+        }
+    }
+    function renderCharts(data) {
+        var devices = data.devices || [];
+        trendChart.setOption({
+            color: ['#44e0c2', '#79d9ff', '#ffcc66'],
+            tooltip: { trigger: 'axis' },
+            legend: { top: 0, right: 10, textStyle: { color: '#b8d9ee' } },
+            grid: { left: 45, right: 25, bottom: 34, top: 42 },
+            xAxis: { type: 'category', data: data.hours || [], axisLabel: { color: '#9fb6c7' }, axisLine: { lineStyle: { color: '#31536a' } } },
+            yAxis: { type: 'value', name: 'kWh', nameTextStyle: { color: '#9fb6c7' }, axisLabel: { color: '#9fb6c7' }, splitLine: { lineStyle: { color: 'rgba(100,160,190,.16)' } } },
+            series: (data.hourlySeries || []).map(function (item) {
+                return { name: item.name, type: 'line', smooth: true, symbol: 'circle', symbolSize: 5, data: item.data || [] };
+            })
+        });
+        powerChart.setOption({
+            color: ['#44e0c2'],
+            tooltip: {},
+            grid: { left: 45, right: 25, bottom: 34, top: 20 },
+            xAxis: { type: 'category', data: devices.map(function (d) { return d.deviceSn || '-'; }), axisLabel: { color: '#9fb6c7' }, axisLine: { lineStyle: { color: '#31536a' } } },
+            yAxis: { type: 'value', name: 'kW', nameTextStyle: { color: '#9fb6c7' }, axisLabel: { color: '#9fb6c7' }, splitLine: { lineStyle: { color: 'rgba(100,160,190,.16)' } } },
+            series: [{ type: 'bar', barWidth: 36, data: devices.map(function (d) { return Number(d.power || 0); }) }]
+        });
+    }
+    function loadData() {
+        $.get('${ctx}/mes/mesProduct/energyScreenData', function (resp) {
+            if (!resp || resp.result !== 'true') { return; }
+            var data = resp.data || {};
+            var devices = data.devices || [];
+            renderTopMeters(devices);
+            renderDevices(devices);
+            renderCharts(data);
+        });
+    }
+    updateClock();
+    loadData();
+    setInterval(updateClock, 1000);
+    setInterval(loadData, 10000);
+    window.addEventListener('resize', function () {
+        trendChart.resize();
+        powerChart.resize();
+    });
+</script>
+</body>
+</html>

+ 15 - 2
src/main/resources/views/modules/mes/mesScreen4.html

@@ -14,7 +14,14 @@
             box-sizing: border-box;
         }
         body {
+            width: 100vw;
+            height: 100vh;
             overflow: hidden;
+            background-color: #00091A;
+            background-image: url("${ctxStatic}/screen/imgs/bgScreen.png");
+            background-size: cover;
+            background-position: center;
+            background-repeat: no-repeat;
         }
         .mes3-wrapper {
             position: relative;
@@ -25,7 +32,8 @@
             background-size: 100% 100%;
             background-position: center;
             overflow: hidden;
-            margin: 0 auto;
+            margin: 0;
+            transform-origin: left top;
         }
 
         /* ===== 顶部标题栏 ===== */
@@ -1754,7 +1762,12 @@
         function changeZoom() {
             var wb = $(window).width() / 1920;
             var hb = $(window).height() / 1080;
-            $('body').css({'zoom': wb >= hb ? hb : wb});
+            var scale = Math.min(wb, hb);
+            var left = ($(window).width() - 1920 * scale) / 2;
+            var top = ($(window).height() - 1080 * scale) / 2;
+            $('.mes3-wrapper').css({
+                transform: 'translate(' + left + 'px,' + top + 'px) scale(' + scale + ')'
+            });
         }
 
         $(window).resize(function() {

+ 266 - 0
tools/build_screen_data_doc.py

@@ -0,0 +1,266 @@
+from docx import Document
+from docx.enum.table import WD_TABLE_ALIGNMENT, WD_CELL_VERTICAL_ALIGNMENT
+from docx.enum.text import WD_ALIGN_PARAGRAPH
+from docx.oxml import OxmlElement
+from docx.oxml.ns import qn
+from docx.shared import Inches, Pt, RGBColor
+
+
+OUT = "大屏数据来源说明-客户版.docx"
+
+
+def shade(cell, fill):
+    tc_pr = cell._tc.get_or_add_tcPr()
+    shd = tc_pr.find(qn("w:shd"))
+    if shd is None:
+        shd = OxmlElement("w:shd")
+        tc_pr.append(shd)
+    shd.set(qn("w:fill"), fill)
+
+
+def set_east_asia_font(run, font_name="Microsoft YaHei"):
+    run.font.name = "Calibri"
+    r_pr = run._element.get_or_add_rPr()
+    r_fonts = r_pr.rFonts
+    if r_fonts is None:
+        r_fonts = OxmlElement("w:rFonts")
+        r_pr.append(r_fonts)
+    r_fonts.set(qn("w:eastAsia"), font_name)
+
+
+def set_table_geometry(table, widths):
+    table.autofit = False
+    table.alignment = WD_TABLE_ALIGNMENT.CENTER
+    tbl_pr = table._tbl.tblPr
+    tbl_w = tbl_pr.find(qn("w:tblW"))
+    if tbl_w is None:
+        tbl_w = OxmlElement("w:tblW")
+        tbl_pr.append(tbl_w)
+    tbl_w.set(qn("w:w"), str(sum(int(w.inches * 1440) for w in widths)))
+    tbl_w.set(qn("w:type"), "dxa")
+
+    margins = tbl_pr.find(qn("w:tblCellMar"))
+    if margins is None:
+        margins = OxmlElement("w:tblCellMar")
+        tbl_pr.append(margins)
+    for name, value in (("top", 80), ("bottom", 80), ("start", 120), ("end", 120)):
+        node = margins.find(qn(f"w:{name}"))
+        if node is None:
+            node = OxmlElement(f"w:{name}")
+            margins.append(node)
+        node.set(qn("w:w"), str(value))
+        node.set(qn("w:type"), "dxa")
+
+    for row in table.rows:
+        for i, width in enumerate(widths):
+            cell = row.cells[i]
+            cell.width = width
+            cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER
+            tc_pr = cell._tc.get_or_add_tcPr()
+            tc_w = tc_pr.find(qn("w:tcW"))
+            if tc_w is None:
+                tc_w = OxmlElement("w:tcW")
+                tc_pr.append(tc_w)
+            tc_w.set(qn("w:w"), str(int(width.inches * 1440)))
+            tc_w.set(qn("w:type"), "dxa")
+
+
+def repeat_header(row):
+    tr_pr = row._tr.get_or_add_trPr()
+    node = OxmlElement("w:tblHeader")
+    node.set(qn("w:val"), "true")
+    tr_pr.append(node)
+
+
+def setup_doc(doc):
+    sec = doc.sections[0]
+    sec.top_margin = Inches(1)
+    sec.bottom_margin = Inches(1)
+    sec.left_margin = Inches(1)
+    sec.right_margin = Inches(1)
+    sec.header_distance = Inches(0.492)
+    sec.footer_distance = Inches(0.492)
+
+    normal = doc.styles["Normal"]
+    normal.font.name = "Calibri"
+    normal._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft YaHei")
+    normal.font.size = Pt(11)
+    normal.paragraph_format.space_after = Pt(6)
+    normal.paragraph_format.line_spacing = 1.10
+
+    for name, size, color, before, after in [
+        ("Heading 1", 16, "2E74B5", 16, 8),
+        ("Heading 2", 13, "2E74B5", 12, 6),
+    ]:
+        style = doc.styles[name]
+        style.font.name = "Calibri"
+        style._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft YaHei")
+        style.font.size = Pt(size)
+        style.font.bold = True
+        style.font.color.rgb = RGBColor.from_string(color)
+        style.paragraph_format.space_before = Pt(before)
+        style.paragraph_format.space_after = Pt(after)
+
+
+def add_title(doc):
+    p = doc.add_paragraph()
+    p.alignment = WD_ALIGN_PARAGRAPH.CENTER
+    r = p.add_run("MES 大屏数据来源说明")
+    r.bold = True
+    r.font.size = Pt(22)
+    r.font.color.rgb = RGBColor.from_string("0B2545")
+    set_east_asia_font(r)
+
+    sub = doc.add_paragraph()
+    sub.alignment = WD_ALIGN_PARAGRAPH.CENTER
+    r2 = sub.add_run("客户版:按大屏页面说明每项数据来自哪个业务模块")
+    r2.font.size = Pt(10)
+    r2.font.color.rgb = RGBColor.from_string("5B677A")
+    set_east_asia_font(r2)
+
+
+def add_note(doc, text):
+    table = doc.add_table(rows=1, cols=1)
+    table.style = "Table Grid"
+    cell = table.rows[0].cells[0]
+    cell.text = text
+    shade(cell, "F4F6F9")
+    set_table_geometry(table, [Inches(6.5)])
+    doc.add_paragraph()
+
+
+def add_table(doc, rows):
+    table = doc.add_table(rows=1, cols=3)
+    table.style = "Table Grid"
+    headers = ["大屏展示内容", "数据来源", "统计说明"]
+    for i, header in enumerate(headers):
+        cell = table.rows[0].cells[i]
+        cell.text = header
+        shade(cell, "F2F4F7")
+        for p in cell.paragraphs:
+            for run in p.runs:
+                run.bold = True
+    for item, source, rule in rows:
+        cells = table.add_row().cells
+        cells[0].text = item
+        cells[1].text = source
+        cells[2].text = rule
+    repeat_header(table.rows[0])
+    set_table_geometry(table, [Inches(1.75), Inches(2.15), Inches(2.60)])
+    doc.add_paragraph()
+
+
+def bullet(doc, text):
+    p = doc.add_paragraph(style="List Bullet")
+    p.paragraph_format.left_indent = Inches(0.5)
+    p.paragraph_format.first_line_indent = Inches(-0.25)
+    p.paragraph_format.space_after = Pt(8)
+    p.paragraph_format.line_spacing = 1.167
+    p.add_run(text)
+
+
+def main():
+    doc = Document()
+    setup_doc(doc)
+    add_title(doc)
+    add_note(doc, "说明:本文档只说明大屏上各类数据从系统哪个业务模块取得,以及目前页面采用的大致统计口径,不涉及代码、接口和数据库字段。")
+
+    doc.add_heading("一、MES 综合数据大屏", level=1)
+    add_table(doc, [
+        ("人员数量", "用户管理 / 员工账号信息", "统计系统中已维护的员工用户数量。"),
+        ("设备数量", "设备管理", "统计系统中已维护的设备总数。"),
+        ("计划产量", "系统配置的大屏计划值", "读取系统配置中的计划产量;部分工位计划会优先读取大屏计划管理中的配置。"),
+        ("实际产量", "产品生产记录", "统计系统中已生成的生产产品数量。页面部分位置展示累计产量,部分趋势图按日期统计。"),
+        ("今日 / 近七日产量", "产品生产记录", "按生产记录的生成时间,分别统计当天、近七天或每小时产量。"),
+        ("小时产量", "产品生产记录", "按小时统计当天产量;综合图表中主要展示 8 点到 18 点的小时产量。"),
+        ("返修数量", "返修管理", "按当天返修记录统计,部分地方按产线和工件编号去重后计算。"),
+        ("报废数量", "报废管理", "统计系统中的报废记录数量。"),
+        ("产线不良率报警", "生产记录 + 返修记录", "按产线比较当天生产数量和返修数量,超过设定比例后在大屏显示报警。"),
+        ("工具剩余寿命", "工具寿命管理", "读取正在使用的工具及其当前剩余寿命。"),
+        ("出库数量", "发运 / 出库记录", "按出库记录中的数量汇总,展示今日、本月或近七日出库情况。"),
+        ("当前工位工件", "工位在制信息", "展示当前各工位正在加工或流转的工件编号。"),
+        ("不良问题统计", "返修问题清单", "按返修问题类型汇总数量,用于显示问题类型分布。"),
+        ("设备报警排行", "设备报警记录", "按报警类型汇总数量,展示报警较多的类型。"),
+        ("设备故障率趋势", "设备管理 + 设备报警记录", "按天统计发生过报警的设备数量,再与设备总数比较得到故障率。"),
+        ("NG 超时提醒", "生产过站 / 检测记录", "查找长时间未处理的 NG 记录,在大屏上做提醒。"),
+        ("工位产能列表", "工艺工位 + 生产过站记录 + 大屏计划", "按工位统计今日完成数量、近 12 小时完成数量,并展示对应计划产能。"),
+    ])
+
+    doc.add_heading("二、小时产量看板", level=1)
+    add_table(doc, [
+        ("HEVXT 上线数量", "HEVXT 产线生产过站记录", "统计 HEVXT 产线当天每小时上线工位的记录数量。"),
+        ("HEVXT 下线数量", "HEVXT 产线生产过站记录", "统计 HEVXT 产线当天每小时下线工位的记录数量。"),
+        ("B13XT 上线数量", "B13XT 产线生产过站记录", "统计 B13XT 产线当天每小时上线工位的记录数量。"),
+        ("B13XT 下线数量", "B13XT 产线生产过站记录", "统计 B13XT 产线当天每小时下线工位的记录数量。"),
+    ])
+
+    doc.add_heading("三、质量管理大屏", level=1)
+    add_table(doc, [
+        ("今日检查数量", "产品检验记录", "统计当天产生的产品检验记录数量。"),
+        ("今日首检数量", "首件检验记录", "统计当天产生的首件检验记录数量。"),
+        ("NG 数量", "NG 记录", "统计当天或近七天的 NG 记录数量。"),
+        ("返修数量", "返修管理", "统计当天或近七天的返修记录数量。"),
+        ("报废数量", "报废管理", "统计当天或近七天的报废记录数量。"),
+        ("待返修数量", "返修管理", "统计当前仍处于待处理状态的返修记录。"),
+        ("质量趋势", "检验、NG、返修、报废记录", "按最近七天分别汇总检查、NG、返修和报废数量。"),
+        ("检查状态分布", "产品检验记录", "按检验状态汇总数量。"),
+        ("检查类型分布", "产品检验记录", "按检验类型汇总数量。"),
+        ("首检状态分布", "首件检验记录", "按首件检验状态汇总数量。"),
+        ("NG 类型分布", "NG 记录", "按 NG 类型汇总数量。"),
+        ("返修状态 / 结果分布", "返修管理", "按返修状态和返修结果汇总数量。"),
+        ("报废原因排行", "报废管理", "按报废原因汇总,展示数量较多的原因。"),
+        ("责任工位排行", "检验、NG、返修记录", "按相关工位汇总质量问题数量。"),
+        ("最新质量问题", "检验、NG、返修、报废记录", "展示最近产生的质量异常或处理记录。"),
+    ])
+
+    doc.add_heading("四、物料管理看板", level=1)
+    add_table(doc, [
+        ("物料总数", "物料基础资料", "统计系统中已维护的物料数量。"),
+        ("库存批次数", "库存台账", "统计当前仍有库存数量的批次。"),
+        ("库存总量", "库存台账", "汇总当前正库存数量。"),
+        ("今日入库数量", "入库记录", "汇总当天入库数量。"),
+        ("今日出库数量", "出库记录", "汇总当天出库数量。"),
+        ("今日报检数量", "物料报检记录", "统计当天产生的报检记录数量。"),
+        ("待处理报检", "物料报检记录", "统计当前仍未完成处理的报检记录。"),
+        ("今日异常数量", "不良品、退货、报废记录", "汇总当天物料异常相关记录数量。"),
+        ("效期预警", "库存台账", "统计未来 30 天内即将到期的库存批次。"),
+        ("物料趋势", "入库、出库、报检和异常记录", "按最近七天汇总业务发生数量。"),
+        ("报检 / 检查 / 不良状态", "物料报检、检查、不良品记录", "按状态汇总数量,用于饼图展示。"),
+        ("入库类型分布", "入库单据", "按入库类型汇总数量。"),
+        ("领料状态分布", "领料单据", "按领料状态汇总数量。"),
+        ("库位库存排行", "库存台账", "按库位汇总库存数量,展示库存较多的库位。"),
+        ("物料库存排行", "库存台账", "按物料汇总库存数量,展示库存较多的物料。"),
+        ("库存明细 / 到期清单", "库存台账", "展示当前库存和即将到期的重点批次。"),
+        ("最新业务动态", "报检、检查、入库、出库及异常记录", "展示最近发生的物料业务记录。"),
+    ])
+
+    doc.add_heading("五、设备 OEE 大屏", level=1)
+    add_table(doc, [
+        ("设备总数", "设备管理", "统计系统中已维护的设备数量。"),
+        ("运行 / 故障 / 维修 / 报警 / 离线数量", "设备实时状态记录 + 设备管理", "读取每台设备最新状态后按状态分类汇总。"),
+        ("设备开动率", "设备状态记录", "按当天设备处于运行状态的时长计算,运行时长占当天已过时间的比例。"),
+        ("平均开动率", "设备状态记录", "把所有设备当天运行时长汇总后,计算整体平均开动率。"),
+        ("设备卡片", "设备管理 + 设备状态记录", "展示设备名称、编号、所属产线、当前状态、最近上报时间和开动率。"),
+        ("开动率排行", "设备状态记录", "按设备当天开动率排序,展示开动率较高的设备。"),
+    ])
+
+    add_note(doc, "特别说明:当前设备 OEE 大屏展示的是“设备开动率”,不是完整 OEE。完整 OEE 通常还需要计划运行时间、理想节拍、总产量、良品数量等数据。")
+
+    doc.add_heading("六、客户确认建议", level=1)
+    for text in [
+        "确认综合大屏顶部统计希望展示“今日数据”“本月数据”还是“历史累计数据”。",
+        "确认生产日期口径使用自然日 00:00-24:00,还是生产班次日 08:00-次日 08:00。",
+        "确认质量大屏中良品、不良是否必须全部来自真实检验数据。",
+        "确认设备 OEE 是否只展示开动率,还是后续需要扩展为完整 OEE。",
+        "确认报警、排行类数据是否需要限定为今日、近七天或本月,避免历史累计影响展示判断。",
+    ]:
+        bullet(doc, text)
+
+    footer = doc.sections[0].footer.paragraphs[0]
+    footer.alignment = WD_ALIGN_PARAGRAPH.RIGHT
+    footer.add_run("MES 大屏数据来源说明")
+    doc.save(OUT)
+
+
+if __name__ == "__main__":
+    main()