Quellcode durchsuchen

feat(clientRuntime): 客户端按类型分离与远程配置下发
- 客户端身份键改为 (client_type, host_ip),clientId 由服务端根据 remoteAddr 生成,避免客户端伪造与换工位残留
- MesClientVersion 按 client_type 分类管理(MANUAL/QIMI/RIVETING),版本发布下拉切换类型
- 客户端监控 tab:LEFT JOIN mes_line_process 展示工位工艺名,按 sorts、oprno 排序
- 心跳新增 current_config_json 字段,展示客户端上报的本地 config.properties
- 配置/升级弹窗:目标 JAR 下拉列出该类型可用版本;配置 JSON 回显客户端当前内容并支持编辑下发
- 修 MesClientRuntime dao:手写 SQL 绕开 DataEntity 对 status 字段的默认过滤(status 复用为 RUNNING/ERROR 语义)
- tab 状态持久化到 URL hash,切换与刷新都保持当前视图
- 新增/编辑版本改为 layer 弹窗,保存后自动 reload 列表
- 客户端类型下拉改为原生 select,修复 jeesite form:select 手写 option 显示"没有找到匹配项"的问题

DDL:
ALTER TABLE mes_client_runtime ADD UNIQUE KEY uk_type_ip (client_type, host_ip);
ALTER TABLE mes_client_version ADD UNIQUE KEY uk_type_ver (client_type, ver);
ALTER TABLE mes_client_runtime ADD COLUMN current_config_json TEXT NULL;
UPDATE mes_client_runtime SET client_id = CONCAT(client_type, '-', host_ip);

wangxichen vor 10 Stunden
Ursprung
Commit
879f5df071

+ 17 - 0
src/main/java/com/jeesite/modules/mesclient/dao/MesClientRuntimeDao.java

@@ -0,0 +1,17 @@
+package com.jeesite.modules.mesclient.dao;
+
+import com.jeesite.common.dao.CrudDao;
+import com.jeesite.common.mybatis.annotation.MyBatisDao;
+import com.jeesite.modules.mesclient.entity.MesClientRuntime;
+
+@MyBatisDao
+public interface MesClientRuntimeDao extends CrudDao<MesClientRuntime> {
+    MesClientRuntime findByClientId(MesClientRuntime q);
+
+    // 传 Entity,jeesite 拦截器才能注入 sqlMap
+    MesClientRuntime findByTypeAndIp(MesClientRuntime q);
+
+    java.util.List<MesClientRuntime> findRuntimeList();
+
+    int upsert(MesClientRuntime runtime);
+}

+ 1 - 0
src/main/java/com/jeesite/modules/mesclient/dao/MesClientVersionDao.java

@@ -12,4 +12,5 @@ import com.jeesite.modules.mesclient.entity.MesClientVersion;
 @MyBatisDao
 public interface MesClientVersionDao extends CrudDao<MesClientVersion> {
     MesClientVersion findLatestVersion(MesClientVersion mesClientVersion);
+    MesClientVersion findByTypeAndVersion(MesClientVersion mesClientVersion);
 }

+ 148 - 0
src/main/java/com/jeesite/modules/mesclient/entity/MesClientRuntime.java

@@ -0,0 +1,148 @@
+package com.jeesite.modules.mesclient.entity;
+
+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;
+
+import java.util.Date;
+
+@Table(name = "mes_client_runtime", alias = "a", label = "客户端运行实例", columns = {
+        @Column(name = "id", attrName = "id", label = "id", isPK = true),
+        @Column(name = "client_id", attrName = "clientId", label = "client_id", queryType = QueryType.LIKE),
+        @Column(name = "client_type", attrName = "clientType", label = "client_type"), @Column(name = "line_sn", attrName = "lineSn", label = "line_sn"),
+        @Column(name = "station_code", attrName = "stationCode", label = "station_code"), @Column(name = "host_ip", attrName = "hostIp", label = "host_ip"),
+        @Column(name = "jar_version", attrName = "jarVersion", label = "jar_version"), @Column(name = "config_version", attrName = "configVersion", label = "config_version"),
+        @Column(name = "status", attrName = "status", label = "status"), @Column(name = "status_message", attrName = "statusMessage", label = "status_message", queryType = QueryType.LIKE),
+        @Column(name = "last_heartbeat", attrName = "lastHeartbeat", label = "last_heartbeat"), @Column(name = "desired_jar_version", attrName = "desiredJarVersion", label = "desired_jar_version"),
+        @Column(name = "desired_config_version", attrName = "desiredConfigVersion", label = "desired_config_version"), @Column(name = "desired_config_json", attrName = "desiredConfigJson", label = "desired_config_json"),
+        @Column(name = "create_by", attrName = "createBy", label = "create_by", isUpdate = false, isQuery = false), @Column(name = "create_date", attrName = "createDate", label = "create_date", isUpdate = false, isQuery = false),
+        @Column(name = "update_by", attrName = "updateBy", label = "update_by", isQuery = false), @Column(name = "update_date", attrName = "updateDate", label = "update_date", isQuery = false)
+}, orderBy = "a.last_heartbeat DESC")
+public class MesClientRuntime extends DataEntity<MesClientRuntime> {
+    private String clientId, clientType, lineSn, stationCode, hostIp, status, statusMessage, desiredConfigJson;
+    private Long jarVersion, configVersion, desiredJarVersion, desiredConfigVersion;
+    private Date lastHeartbeat;
+    // 关联 mes_line_process 带出,非数据库字段
+    private String stationTitle;
+    private Integer stationSorts;
+    private String currentConfigJson;   // 客户端上报的本地当前配置
+    public String getStationTitle() { return stationTitle; }
+    public void setStationTitle(String v) { stationTitle = v; }
+    public Integer getStationSorts() { return stationSorts; }
+    public void setStationSorts(Integer v) { stationSorts = v; }
+    public String getCurrentConfigJson() { return currentConfigJson; }
+    public void setCurrentConfigJson(String v) { currentConfigJson = v; }
+
+    public MesClientRuntime() {
+        this(null);
+    }
+
+    public MesClientRuntime(String id) {
+        super(id);
+    }
+
+    public String getClientId() {
+        return clientId;
+    }
+
+    public void setClientId(String v) {
+        clientId = v;
+    }
+
+    public String getClientType() {
+        return clientType;
+    }
+
+    public void setClientType(String v) {
+        clientType = v;
+    }
+
+    public String getLineSn() {
+        return lineSn;
+    }
+
+    public void setLineSn(String v) {
+        lineSn = v;
+    }
+
+    public String getStationCode() {
+        return stationCode;
+    }
+
+    public void setStationCode(String v) {
+        stationCode = v;
+    }
+
+    public String getHostIp() {
+        return hostIp;
+    }
+
+    public void setHostIp(String v) {
+        hostIp = v;
+    }
+
+    public String getStatus() {
+        return status;
+    }
+
+    public void setStatus(String v) {
+        status = v;
+    }
+
+    public String getStatusMessage() {
+        return statusMessage;
+    }
+
+    public void setStatusMessage(String v) {
+        statusMessage = v;
+    }
+
+    public String getDesiredConfigJson() {
+        return desiredConfigJson;
+    }
+
+    public void setDesiredConfigJson(String v) {
+        desiredConfigJson = v;
+    }
+
+    public Long getJarVersion() {
+        return jarVersion;
+    }
+
+    public void setJarVersion(Long v) {
+        jarVersion = v;
+    }
+
+    public Long getConfigVersion() {
+        return configVersion;
+    }
+
+    public void setConfigVersion(Long v) {
+        configVersion = v;
+    }
+
+    public Long getDesiredJarVersion() {
+        return desiredJarVersion;
+    }
+
+    public void setDesiredJarVersion(Long v) {
+        desiredJarVersion = v;
+    }
+
+    public Long getDesiredConfigVersion() {
+        return desiredConfigVersion;
+    }
+
+    public void setDesiredConfigVersion(Long v) {
+        desiredConfigVersion = v;
+    }
+
+    public Date getLastHeartbeat() {
+        return lastHeartbeat;
+    }
+
+    public void setLastHeartbeat(Date v) {
+        lastHeartbeat = v;
+    }
+}

+ 4 - 0
src/main/java/com/jeesite/modules/mesclient/entity/MesClientVersion.java

@@ -10,6 +10,7 @@ import com.jeesite.common.mybatis.mapper.query.QueryType;
 @Table(name="mes_client_version", alias="a", label="Client Version", columns={
     @Column(name="id", attrName="id", label="id", isPK=true),
     @Column(name="ver", attrName="ver", label="version", isUpdateForce=true),
+    @Column(name="client_type", attrName="clientType", label="client_type"),
     @Column(name="content", attrName="content", label="content", queryType=QueryType.LIKE),
     @Column(name="state", attrName="state", label="state"),
     @Column(name="create_by", attrName="createBy", label="create_by", isUpdate=false, isQuery=false),
@@ -20,6 +21,7 @@ import com.jeesite.common.mybatis.mapper.query.QueryType;
 public class MesClientVersion extends DataEntity<MesClientVersion> {
     private static final long serialVersionUID = 1L;
     private Long ver;
+    private String clientType;
     private String content;
     private String state;
     private String path;
@@ -28,6 +30,8 @@ public class MesClientVersion extends DataEntity<MesClientVersion> {
     public MesClientVersion(String id) { super(id); }
     public Long getVer() { return ver; }
     public void setVer(Long ver) { this.ver = ver; }
+    public String getClientType() { return clientType; }
+    public void setClientType(String clientType) { this.clientType = clientType; }
     public String getContent() { return content; }
     public void setContent(String content) { this.content = content; }
     @NotBlank(message="state is required")

+ 75 - 0
src/main/java/com/jeesite/modules/mesclient/service/MesClientRuntimeService.java

@@ -0,0 +1,75 @@
+package com.jeesite.modules.mesclient.service;
+
+import java.util.Date;
+import java.util.UUID;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.jeesite.common.entity.Page;
+import com.jeesite.common.service.CrudService;
+import com.jeesite.modules.mesclient.dao.MesClientRuntimeDao;
+import com.jeesite.modules.mesclient.entity.MesClientRuntime;
+
+@Service
+public class MesClientRuntimeService extends CrudService<MesClientRuntimeDao, MesClientRuntime> {
+
+    @Autowired
+    private MesClientRuntimeDao dao;
+
+    // 兼容旧接口(比如 poll)
+    public MesClientRuntime findByClientId(String id) {
+        MesClientRuntime q = new MesClientRuntime();
+        q.setClientId(id);
+        return dao.findByClientId(q);
+    }
+
+    // 新身份键查询
+    public MesClientRuntime findByTypeAndIp(String type, String ip) {
+        MesClientRuntime q = new MesClientRuntime();
+        q.setClientType(type);
+        q.setHostIp(ip);
+        return dao.findByTypeAndIp(q);
+    }
+
+    /**
+     * 心跳:以 (clientType, hostIp) 为身份键 upsert。
+     * clientId 由服务端拼装,客户端传的 clientId 会被忽略。
+     */
+    @Transactional
+    public MesClientRuntime heartbeat(MesClientRuntime in) {
+        String type = in.getClientType();
+        String ip = in.getHostIp();
+        if (type == null || type.trim().isEmpty() || ip == null || ip.trim().isEmpty()) {
+            throw new IllegalArgumentException("clientType and hostIp are required");
+        }
+        MesClientRuntime x = findByTypeAndIp(type, ip);
+        if (x == null) {
+            x = new MesClientRuntime();
+            x.setId(UUID.randomUUID().toString().replace("-", ""));
+            x.setClientType(type);
+            x.setHostIp(ip);
+        }
+        x.setClientId(type + "-" + ip);   // 服务端定 clientId
+        x.setLineSn(in.getLineSn());
+        x.setStationCode(in.getStationCode());
+        x.setJarVersion(in.getJarVersion());
+        x.setConfigVersion(in.getConfigVersion());
+        x.setStatus(in.getStatus());
+        x.setStatusMessage(in.getStatusMessage());
+        x.setCurrentConfigJson(in.getCurrentConfigJson());
+        x.setLastHeartbeat(new Date());
+        dao.upsert(x);
+        // upsert 后重新查一遍,把 desired_* 字段带回给客户端
+        return findByTypeAndIp(type, ip);
+    }
+
+    public java.util.List<MesClientRuntime> findRuntimeList() {
+        return dao.findRuntimeList();
+    }
+
+    public Page<MesClientRuntime> findPage(MesClientRuntime x) {
+        return super.findPage(x);
+    }
+}

+ 5 - 0
src/main/java/com/jeesite/modules/mesclient/service/MesClientVersionService.java

@@ -96,4 +96,9 @@ public class MesClientVersionService extends CrudService<MesClientVersionDao, Me
 		return mesClientVersionDao.findLatestVersion(mesClientVersion);
 	}
 	
+
+    public MesClientVersion findByTypeAndVersion(String clientType, Long ver) {
+        MesClientVersion q = new MesClientVersion(); q.setClientType(clientType); q.setVer(ver);
+        return mesClientVersionDao.findByTypeAndVersion(q);
+    }
 }

+ 121 - 0
src/main/java/com/jeesite/modules/mesclient/web/MesClientRuntimeController.java

@@ -0,0 +1,121 @@
+package com.jeesite.modules.mesclient.web;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.apache.shiro.authz.annotation.RequiresPermissions;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.ResponseBody;
+
+import com.jeesite.common.config.Global;
+import com.jeesite.common.entity.Page;
+import com.jeesite.common.web.BaseController;
+import com.jeesite.modules.mes.resp.CommonResp;
+import com.jeesite.modules.mesclient.entity.MesClientRuntime;
+import com.jeesite.modules.mesclient.service.MesClientRuntimeService;
+
+@Controller
+@RequestMapping("${adminPath}/mes/clientRuntime")
+public class MesClientRuntimeController extends BaseController {
+
+    @Autowired
+    private MesClientRuntimeService service;
+
+    @RequiresPermissions("mes:clientVersion:view")
+    @RequestMapping(value = {"list", ""})
+    public String list(MesClientRuntime x, Model m) {
+        m.addAttribute("mesClientRuntime", x);
+        return "modules/mesclient/mesClientRuntimeList";
+    }
+
+    // 走 jeesite 标准分页,前端 dataGrid 能直接认
+    @RequiresPermissions("mes:clientVersion:view")
+    @RequestMapping("listData")
+    @ResponseBody
+    public Page<MesClientRuntime> listData(MesClientRuntime x, HttpServletRequest req, HttpServletResponse resp) {
+        x.setPage(new Page<>(req, resp));
+        return service.findPage(x);
+    }
+
+    /**
+     * 客户端心跳。身份键 = (clientType, remoteAddr),clientId 由服务端生成。
+     * 客户端传的 clientId 会被忽略。
+     */
+    @PostMapping("/heartbeat")
+    @ResponseBody
+    public CommonResp heartbeat(HttpServletRequest req) {
+        CommonResp r = new CommonResp();
+        MesClientRuntime x = new MesClientRuntime();
+        x.setClientType(req.getParameter("clientType"));
+        x.setLineSn(req.getParameter("lineSn"));
+        x.setStationCode(req.getParameter("stationCode"));
+        x.setHostIp(req.getRemoteAddr());          // 从连接层拿,防伪造
+        x.setJarVersion(num(req.getParameter("jarVersion")));
+        x.setConfigVersion(num(req.getParameter("configVersion")));
+        x.setStatus(req.getParameter("status"));
+        x.setStatusMessage(req.getParameter("statusMessage"));
+        x.setCurrentConfigJson(req.getParameter("currentConfigJson"));
+        if (x.getClientType() == null || x.getClientType().trim().isEmpty()) {
+            r.setResult(Global.FALSE);
+            r.setMessage("clientType required");
+            return r;
+        }
+        try {
+            r.setData(service.heartbeat(x));
+        } catch (IllegalArgumentException e) {
+            r.setResult(Global.FALSE);
+            r.setMessage(e.getMessage());
+        }
+        return r;
+    }
+
+    @RequiresPermissions("mes:clientVersion:edit")
+    @PostMapping("/command")
+    @ResponseBody
+    public String command(@RequestParam String id, Long desiredJarVersion, Long desiredConfigVersion, String desiredConfigJson) {
+        MesClientRuntime x = service.get(id);
+        if (x == null) return renderResult(Global.FALSE, "客户端不存在");
+        x.setDesiredJarVersion(desiredJarVersion);
+        x.setDesiredConfigVersion(desiredConfigVersion);
+        x.setDesiredConfigJson(desiredConfigJson);
+        service.save(x);
+        return renderResult(Global.TRUE, "客户端任务已下发");
+    }
+
+    // 保留:客户端主动轮询(可选,心跳响应里已经带 desired_*)
+    @GetMapping("/poll")
+    @ResponseBody
+    public CommonResp poll(@RequestParam String clientId) {
+        CommonResp r = new CommonResp();
+        MesClientRuntime x = service.findByClientId(clientId);
+        if (x == null) {
+            r.setResult(Global.FALSE);
+            r.setMessage("client not registered");
+        } else {
+            r.setData(x);
+        }
+        return r;
+    }
+
+    // 前端配置弹窗用:按主键查单条
+    @RequiresPermissions("mes:clientVersion:view")
+    @GetMapping("/get")
+    @ResponseBody
+    public CommonResp getById(@RequestParam String id) {
+        CommonResp r = new CommonResp();
+        MesClientRuntime x = service.get(id);
+        if (x == null) { r.setResult(Global.FALSE); r.setMessage("not found"); }
+        else r.setData(x);
+        return r;
+    }
+
+    private Long num(String s) {
+        try { return s == null ? null : Long.valueOf(s); } catch (Exception e) { return null; }
+    }
+}

+ 12 - 1
src/main/java/com/jeesite/modules/mesclient/web/MesClientVersionController.java

@@ -114,7 +114,18 @@ public class MesClientVersionController extends BaseController {
 	public CommonResp ver(HttpServletRequest req) {
 		String host = req.getScheme() + "://" + req.getServerName() + ":" + req.getServerPort() + "/js";
 		CommonResp<MesClientVersion> resp = new CommonResp<>();
-		MesClientVersion mesClientVersion = mesClientVersionService.findLatestVersion();
+		String clientType = req.getParameter("clientType");
+        String requestedVersion = req.getParameter("version");
+        MesClientVersion mesClientVersion;
+        if (requestedVersion != null && !requestedVersion.trim().isEmpty() && clientType != null) {
+            try { mesClientVersion = mesClientVersionService.findByTypeAndVersion(clientType, Long.valueOf(requestedVersion)); } catch (Exception e) { mesClientVersion = null; }
+        } else if (clientType == null || clientType.trim().isEmpty()) {
+            mesClientVersion = mesClientVersionService.findLatestVersion();
+        } else {
+            MesClientVersion q = new MesClientVersion(); q.setClientType(clientType); q.setState("0");
+            java.util.List<MesClientVersion> list = mesClientVersionService.findList(q);
+            mesClientVersion = list.isEmpty() ? null : list.get(0);
+        }
 		if(ObjectUtils.isEmpty(mesClientVersion)){
 			resp.setResult(Global.FALSE);
 			resp.setMessage("No available client version");

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

@@ -617,6 +617,8 @@ shiro:
   filterChainDefinitions: |
     ${adminPath}/mes/mesApp/ver = anon
     ${adminPath}/mes/clientVersion/ver = anon
+    ${adminPath}/mes/clientRuntime/heartbeat = anon
+    ${adminPath}/mes/clientRuntime/poll = anon
     ${adminPath}/mes/mesLogin/login = anon
     ${adminPath}/mes/pda/product/steelSn = anon
     ${adminPath}/mes/mesProductQm/add = anon

+ 76 - 0
src/main/resources/mappings/modules/mesclient/MesClientRuntimeDao.xml

@@ -0,0 +1,76 @@
+<?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.mesclient.dao.MesClientRuntimeDao">
+
+    <sql id="cols">
+        id, client_id AS clientId, client_type AS clientType, line_sn AS lineSn,
+        station_code AS stationCode, host_ip AS hostIp,
+        jar_version AS jarVersion, config_version AS configVersion,
+        status, status_message AS statusMessage, last_heartbeat AS lastHeartbeat,
+        desired_jar_version AS desiredJarVersion, desired_config_version AS desiredConfigVersion,
+        desired_config_json AS desiredConfigJson,
+        current_config_json AS currentConfigJson,
+        create_by AS createBy, create_date AS createDate,
+        update_by AS updateBy, update_date AS updateDate
+    </sql>
+
+    <!-- CrudService.findPage 走这里。LEFT JOIN mes_line_process 带出工艺名和排序 -->
+    <select id="findList" resultType="com.jeesite.modules.mesclient.entity.MesClientRuntime">
+        SELECT
+          r.id, r.client_id AS clientId, r.client_type AS clientType, r.line_sn AS lineSn,
+          r.station_code AS stationCode, r.host_ip AS hostIp,
+          r.jar_version AS jarVersion, r.config_version AS configVersion,
+          r.status, r.status_message AS statusMessage, r.last_heartbeat AS lastHeartbeat,
+          r.desired_jar_version AS desiredJarVersion, r.desired_config_version AS desiredConfigVersion,
+          r.desired_config_json AS desiredConfigJson,
+          r.current_config_json AS currentConfigJson,
+          r.create_by AS createBy, r.create_date AS createDate,
+          r.update_by AS updateBy, r.update_date AS updateDate,
+          p.title AS stationTitle, p.sorts AS stationSorts
+        FROM mes_client_runtime r
+        LEFT JOIN mes_line_process p ON p.oprno = r.station_code AND p.status = '0'
+        <where>
+            <if test="clientId != null and clientId != ''">AND r.client_id LIKE CONCAT('%', #{clientId}, '%')</if>
+            <if test="clientType != null and clientType != ''">AND r.client_type = #{clientType}</if>
+            <if test="stationCode != null and stationCode != ''">AND r.station_code = #{stationCode}</if>
+            <if test="lineSn != null and lineSn != ''">AND r.line_sn = #{lineSn}</if>
+            <if test="statusMessage != null and statusMessage != ''">AND r.status_message LIKE CONCAT('%', #{statusMessage}, '%')</if>
+        </where>
+        ORDER BY
+          CASE WHEN p.sorts IS NULL THEN 1 ELSE 0 END,
+          p.sorts ASC,
+          r.station_code ASC
+    </select>
+
+    <select id="findRuntimeList" resultType="com.jeesite.modules.mesclient.entity.MesClientRuntime">
+        SELECT <include refid="cols"/> FROM mes_client_runtime ORDER BY last_heartbeat DESC
+    </select>
+
+    <select id="findByClientId" resultType="com.jeesite.modules.mesclient.entity.MesClientRuntime">
+        SELECT <include refid="cols"/> FROM mes_client_runtime
+        WHERE client_id = #{clientId} LIMIT 1
+    </select>
+
+    <select id="findByTypeAndIp" resultType="com.jeesite.modules.mesclient.entity.MesClientRuntime">
+        SELECT <include refid="cols"/> FROM mes_client_runtime
+        WHERE client_type = #{clientType} AND host_ip = #{hostIp} LIMIT 1
+    </select>
+
+    <insert id="upsert">
+        INSERT INTO mes_client_runtime
+        (id, client_id, client_type, line_sn, station_code, host_ip, jar_version, config_version,
+         status, status_message, last_heartbeat, current_config_json, create_date, update_date)
+        VALUES
+        (#{id}, #{clientId}, #{clientType}, #{lineSn}, #{stationCode}, #{hostIp}, #{jarVersion}, #{configVersion},
+         #{status}, #{statusMessage}, #{lastHeartbeat}, #{currentConfigJson}, NOW(), NOW())
+        ON DUPLICATE KEY UPDATE
+          client_id=VALUES(client_id),
+          line_sn=VALUES(line_sn), station_code=VALUES(station_code),
+          jar_version=VALUES(jar_version), config_version=VALUES(config_version),
+          status=VALUES(status), status_message=VALUES(status_message),
+          last_heartbeat=VALUES(last_heartbeat),
+          current_config_json=VALUES(current_config_json),
+          update_date=NOW()
+    </insert>
+
+</mapper>

+ 1 - 0
src/main/resources/mappings/modules/mesclient/MesClientVersionDao.xml

@@ -20,4 +20,5 @@
 		ORDER BY create_date DESC
 		Limit 1
 	</select>
+<select id="findByTypeAndVersion" resultType="com.jeesite.modules.mesclient.entity.MesClientVersion">SELECT ${sqlMap.column.toSql()} FROM ${sqlMap.table.toSql()} WHERE client_type = #{clientType} AND ver = #{ver} AND state = '0' LIMIT 1</select>
 </mapper>

+ 63 - 0
src/main/resources/views/modules/mesclient/mesClientRuntimeList.html

@@ -0,0 +1,63 @@
+<% layout('/layouts/default.html', {title: '客户端运行监控', libs: ['dataGrid']}){ %>
+<div class="main-content">
+    <div class="box box-main">
+        <div class="box-header">
+            <div class="box-title"><i class="fa icon-monitor"></i> 客户端运行监控</div>
+        </div>
+        <div class="box-body">
+            <table id="dataGrid"></table>
+            <div id="dataGridPage"></div>
+        </div>
+    </div>
+</div><% } %>
+<script>
+    function pushConfig(id) {
+        var ver = prompt('配置版本号');
+        if (ver === null) return;
+        var jar = prompt('目标JAR版本(留空不升级)');
+        if (jar === null) return;
+        var json = prompt('配置JSON(留空不修改配置)');
+        if (json === null) return;
+        $.post('${ctx}/mes/clientRuntime/command', {
+            id: id,
+            desiredJarVersion: jar === '' ? null : jar,
+            desiredConfigVersion: ver === '' ? null : ver,
+            desiredConfigJson: json
+        }, function (d) {
+            js.showMessage(d.message || '已下发');
+            $('#dataGrid').trigger('reloadGrid');
+        }, 'json');
+    }
+
+    $('#dataGrid').dataGrid({
+        url: '${ctx}/mes/clientRuntime/listData',
+        columnModel: [{header: '客户端编号', name: 'clientId', width: 180}, {
+            header: '类型',
+            name: 'clientType',
+            width: 100
+        }, {header: '工位', name: 'stationCode', width: 100}, {
+            header: 'IP',
+            name: 'hostIp',
+            width: 130
+        }, {header: 'JAR版本', name: 'jarVersion', width: 80}, {
+            header: '配置版本',
+            name: 'configVersion',
+            width: 80
+        }, {header: '状态', name: 'status', width: 100}, {
+            header: '状态消息',
+            name: 'statusMessage',
+            width: 180
+        }, {header: '最后心跳', name: 'lastHeartbeat', width: 150}, {
+            header: '目标JAR',
+            name: 'desiredJarVersion',
+            width: 80
+        }, {header: '目标配置', name: 'desiredConfigVersion', width: 80}, {
+            header: '操作',
+            name: 'actions',
+            width: 80,
+            formatter: function (v, o, row) {
+                return '<a href="javascript:pushConfig(\'' + row.id + '\')">配置</a>';
+            }
+        }]
+    });
+</script>

Datei-Diff unterdrückt, da er zu groß ist
+ 17 - 3
src/main/resources/views/modules/mesclient/mesClientVersionForm.html


+ 170 - 69
src/main/resources/views/modules/mesclient/mesClientVersionList.html

@@ -1,75 +1,176 @@
 <% layout('/layouts/default.html', {title: '通用客户端版本管理', libs: ['dataGrid']}){ %>
-<div class="main-content">
-	<div class="box box-main">
-		<div class="box-header">
-			<div class="box-title">
-				<i class="fa icon-notebook"></i> ${text('通用客户端版本管理')}
-			</div>
-			<div class="box-tools pull-right">
-				<a href="#" class="btn btn-default" id="btnSearch" title="${text('查询')}"><i class="fa fa-filter"></i> ${text('查询')}</a>
-				<% if(hasPermi('mes:clientVersion:edit')){ %>
-					<a href="${ctx}/mes/clientVersion/form" class="btn btn-default btnTool" title="${text('新增通用客户端版本')}"><i class="fa fa-plus"></i> ${text('新增')}</a>
-				<% } %>
-				<a href="#" class="btn btn-default" id="btnSetting" title="${text('设置')}"><i class="fa fa-navicon"></i></a>
-			</div>
-		</div>
-		<div class="box-body">
-			<#form:form id="searchForm" model="${mesClientVersion}" action="${ctx}/mes/clientVersion/listData" method="post" class="form-inline hide"
-					data-page-no="${parameter.pageNo}" data-page-size="${parameter.pageSize}" data-order-by="${parameter.orderBy}">
-				<div class="form-group">
-					<label class="control-label">${text('版本号')}:</label>
-					<div class="control-inline">
-						<#form:input path="ver" maxlength="9" class="form-control width-120"/>
-					</div>
-				</div>
-				<div class="form-group">
-					<label class="control-label">${text('更新内容')}:</label>
-					<div class="control-inline">
-						<#form:input path="content" class="form-control width-120"/>
-					</div>
-				</div>
-				<div class="form-group">
-					<label class="control-label">${text('状态')}:</label>
-					<div class="control-inline width-120">
-						<#form:select path="state" dictType="mes_app_status" blankOption="true" class="form-control"/>
-					</div>
-				</div>
-				<div class="form-group">
-					<button type="submit" class="btn btn-primary btn-sm">${text('查询')}</button>
-					<button type="reset" class="btn btn-default btn-sm">${text('重置')}</button>
-				</div>
-			</#form:form>
-			<table id="dataGrid"></table>
-			<div id="dataGridPage"></div>
-		</div>
-	</div>
+<div class="main-content"><div class="box box-main">
+<div class="box-header"><div class="box-title"><i class="fa icon-notebook"></i> 通用客户端版本管理</div><div class="box-tools pull-right">
+<% if(hasPermi('mes:clientVersion:edit')){ %><a href="javascript:openVersionForm()" class="btn btn-default btnTool"><i class="fa fa-plus"></i> 新增版本</a><% } %>
+<a href="#" class="btn btn-default" id="btnSetting"><i class="fa fa-navicon"></i></a></div></div>
+<div class="box-body">
+<ul class="nav nav-tabs" role="tablist">
+    <li class="active"><a href="#versionPanel" data-toggle="tab">版本发布</a></li>
+    <li><a href="#runtimePanel" data-toggle="tab">客户端监控</a></li>
+</ul>
+<div class="tab-content" style="padding-top:15px">
+<div id="versionPanel" class="tab-pane active">
+    <#form:form id="searchForm" model="${mesClientVersion}" action="${ctx}/mes/clientVersion/listData" method="post" class="form-inline hide" data-page-no="${parameter.pageNo}" data-page-size="${parameter.pageSize}" data-order-by="${parameter.orderBy}">
+    <#form:input path="ver" class="form-control width-120" placeholder="版本号"/>
+    <#form:input path="content" class="form-control width-180" placeholder="更新内容"/>
+    <select name="clientType" class="form-control"><option value="">全部客户端</option><option value="MANUAL" ${mesClientVersion.clientType == 'MANUAL' ? 'selected' : ''}>手工位通用</option><option value="QIMI" ${mesClientVersion.clientType == 'QIMI' ? 'selected' : ''}>气密通用</option><option value="RIVETING" ${mesClientVersion.clientType == 'RIVETING' ? 'selected' : ''}>拉铆通用</option></select>
+    <#form:select path="state" dictType="mes_app_status" blankOption="true" class="form-control"/>
+    <button type="submit" class="btn btn-primary btn-sm">查询</button>
+    </#form:form>
+    <table id="dataGrid"></table><div id="dataGridPage"></div>
 </div>
+<div id="runtimePanel" class="tab-pane">
+    <table id="runtimeGrid"></table><div id="runtimeGridPage"></div>
+</div>
+</div></div></div></div>
 <% } %>
 <script>
-// 初始化DataGrid对象
+var CLIENT_TYPE_NAME = {MANUAL:'手工位通用', QIMI:'气密通用', RIVETING:'拉铆通用'};
+function clientTypeName(v){ return CLIENT_TYPE_NAME[v] || v || '-'; }
+
+// —— 版本发布 grid ——
 $('#dataGrid').dataGrid({
-	searchForm: $("#searchForm"),
-	columnModel: [
-		{header:'${text("版本号")}', name:'ver', index:'a.ver', width:150, align:"center", frozen:true, formatter: function(val, obj, row, act){
-			return '<a href="${ctx}/mes/clientVersion/form?id='+row.id+'" class="btnList" data-title="${text("编辑通用客户端版本")}">'+(val||row.id)+'</a>';
-		}},
-		{header:'${text("更新内容")}', name:'content', index:'a.content', width:250, align:"center"},
-		{header:'${text("状态")}', name:'state', index:'a.state', width:150, align:"center", formatter: function(val, obj, row, act){
-			return js.getDictLabel(${@DictUtils.getDictListJson('mes_app_status')}, val, '${text("未知")}', true);
-		}},
-		{header:'${text("更新时间")}', name:'updateDate', index:'a.update_date', width:150, align:"center"},
-		{header:'${text("操作")}', name:'actions', width:120, formatter: function(val, obj, row, act){
-			var actions = [];
-			//<% if(hasPermi('mes:clientVersion:edit')){ %>
-				actions.push('<a href="${ctx}/mes/clientVersion/form?id='+row.id+'" class="btnList" title="${text("编辑通用客户端版本")}"><i class="fa fa-pencil"></i></a>&nbsp;');
-				actions.push('<a href="${ctx}/mes/clientVersion/delete?id='+row.id+'" class="btnList" title="${text("删除通用客户端版本")}" data-confirm="${text("确认要删除该通用客户端版本吗?")}"><i class="fa fa-trash-o"></i></a>&nbsp;');
-			//<% } %>
-			return actions.join('');
-		}}
-	],
-	// 加载成功后执行事件
-	ajaxSuccess: function(data){
-		
-	}
+    searchForm: $('#searchForm'),
+    columnModel: [
+        {header:'客户端类型', name:'clientType', width:120, formatter:function(v){return clientTypeName(v);}},
+        {header:'版本号', name:'ver', width:90, formatter:function(v,o,row){return '<a href="javascript:openVersionForm(\''+row.id+'\')" class="btnList">'+(v||row.id)+'</a>';}},
+        {header:'更新内容', name:'content', width:250},
+        {header:'状态', name:'state', width:80, formatter:function(v){return js.getDictLabel(${@DictUtils.getDictListJson('mes_app_status')},v,'未知',true);}},
+        {header:'更新时间', name:'updateDate', width:150},
+        {header:'操作', name:'actions', width:100, formatter:function(v,o,row){return '<a href="javascript:openVersionForm(\''+row.id+'\')" class="btnList"><i class="fa fa-pencil"></i> 编辑</a>';}}
+    ]
+});
+
+// 用 layer 弹窗打开版本表单,保存后自动刷新 grid(替代原 tab 打开)
+function openVersionForm(id){
+    var url = '${ctx}/mes/clientVersion/form' + (id ? '?id='+id : '');
+    top.layer.open({
+        type: 2, title: id ? '编辑版本' : '新增版本',
+        area: ['680px', '560px'], content: url,
+        end: function(){ $('#dataGrid').trigger('reloadGrid'); }
+    });
+}
+
+// —— 客户端监控 grid ——
+function heartbeatCell(v){
+    if(!v) return '<span class="label label-default">未知</span>';
+    var t = new Date(v.replace(' ','T')).getTime();
+    if(isNaN(t)) return '<span class="label label-default">'+v+'</span>';
+    var gap = (Date.now()-t)/1000, lab;
+    if(gap<60) lab='<span class="label label-success">在线</span>';
+    else if(gap<180) lab='<span class="label label-warning">疑似离线</span>';
+    else lab='<span class="label label-default">离线</span>';
+    return lab + ' <span style="color:#888">'+v+'</span>';
+}
+
+$('#runtimeGrid').dataGrid({
+    url: '${ctx}/mes/clientRuntime/listData',
+    pager: '#runtimeGridPage',
+    columnModel: [
+        {header:'类型', name:'clientType', width:110, formatter:function(v){return clientTypeName(v);}},
+        {header:'工位', name:'stationCode', width:100},
+        {header:'工艺', name:'stationTitle', width:170, formatter:function(v){return v||'<span style="color:#bbb">-</span>';}},
+        {header:'IP', name:'hostIp', width:140},
+        {header:'JAR版本', name:'jarVersion', width:90},
+        {header:'配置版本', name:'configVersion', width:90},
+        {header:'心跳/在线', name:'lastHeartbeat', width:260, formatter:function(v){return heartbeatCell(v);}},
+        {header:'目标JAR', name:'desiredJarVersion', width:90},
+        {header:'目标配置', name:'desiredConfigVersion', width:90},
+        {header:'操作', name:'actions', width:130, formatter:function(v,o,row){return '<a href="javascript:openRuntimeDialog(\''+row.id+'\')" class="btn btn-xs btn-primary"><i class="fa fa-cog"></i> 配置/升级</a>';}}
+    ]
+});
+
+// —— 配置/升级弹窗 ——
+function openRuntimeDialog(id){
+    $.getJSON('${ctx}/mes/clientRuntime/get', {id:id}, function(resp){
+        if(!resp || resp.result !== 'true' || !resp.data){ top.layer.msg('未找到该客户端'); return; }
+        renderRuntimeDialog(id, resp.data);
+    });
+}
+function renderRuntimeDialog(id, row){
+    // 优先展示客户端本地当前配置(心跳上报的),拿不到才用模板兜底
+    var cfgStr = row.currentConfigJson || '';
+    var pretty = '';
+    var isTemplate = false;
+    if(cfgStr){
+        try{ pretty = JSON.stringify(JSON.parse(cfgStr), null, 2); }catch(e){ pretty = cfgStr; }
+    } else {
+        isTemplate = true;
+        pretty = JSON.stringify({
+            "mes.server_ip":"192.168.114.99",
+            "mes.gw": row.stationCode || "OP130A",
+            "mes.line_sn": row.lineSn || "XT",
+            "mes.tcp_port":"3000",
+            "mes.heart_beat_cycle":"60"
+        }, null, 2);
+    }
+    var cfgHint = isTemplate
+        ? '客户端尚未上报本地配置(首次心跳会带上)。这是一份模板,可以直接下发。'
+        : '来自客户端最近一次心跳上报的本地 config.properties。修改后下发会覆盖同名 key(其他 key 保留),并触发重启。';
+
+    var html = ''
+        + '<div style="padding:18px 24px">'
+        +   '<div class="form-group"><label>客户端:</label> <b>'+clientTypeName(row.clientType)+' / '+(row.stationCode||'')+(row.stationTitle?'('+row.stationTitle+')':'')+' / '+(row.hostIp||'')+'</b>'
+        +     ' <span style="color:#888">(当前 JAR v'+(row.jarVersion||0)+',配置 v'+(row.configVersion||0)+')</span></div>'
+        +   '<hr style="margin:10px 0">'
+        +   '<div class="form-group"><label>目标 JAR 版本</label>'
+        +     '<select id="dlgJarVer" class="form-control"><option value="">— 不升级 —</option></select>'
+        +     '<small style="color:#888">下拉列出该类型下已启用的版本;改动后客户端将自动下载并重启</small>'
+        +   '</div>'
+        +   '<div class="form-group"><label>本地配置(留空 = 不下发配置)</label>'
+        +     '<textarea id="dlgCfgJson" class="form-control" rows="10" style="font-family:Consolas,monospace;font-size:12px">'+pretty.replace(/</g,'&lt;')+'</textarea>'
+        +     '<small style="color:#888">'+cfgHint+'</small>'
+        +   '</div>'
+        + '</div>';
+
+    top.layer.open({
+        type: 1, title: '配置 / 升级客户端',
+        area: ['620px', '520px'],
+        content: html,
+        btn: ['下发', '关闭'],
+        success: function(layero, index){
+            // 加载该 type 已启用的版本列表填下拉
+            var $sel = $(layero).find('#dlgJarVer');
+            $.post('${ctx}/mes/clientVersion/listData',
+                {'clientType': row.clientType, 'state': '0', 'pageSize': 200, 'pageNo': 1},
+                function(page){
+                    var list = (page && page.list) || [];
+                    list.forEach(function(v){
+                        var sel = (row.desiredJarVersion && String(v.ver) === String(row.desiredJarVersion)) ? ' selected' : '';
+                        $sel.append('<option value="'+v.ver+'"'+sel+'>v'+v.ver+' — '+(v.content||'').replace(/</g,'&lt;')+'</option>');
+                    });
+                }, 'json');
+        },
+        yes: function(index, layero){
+            var jar = $(layero).find('#dlgJarVer').val();
+            var cfg = $.trim($(layero).find('#dlgCfgJson').val());
+            var cv = null;
+            if(cfg){
+                try{ JSON.parse(cfg); }catch(e){ top.layer.msg('配置 JSON 格式错误'); return; }
+                cv = (row.desiredConfigVersion || row.configVersion || 0) + 1;
+            }
+            $.post('${ctx}/mes/clientRuntime/command',
+                {id:id, desiredJarVersion:jar||null, desiredConfigVersion:cv, desiredConfigJson:cfg||null},
+                function(d){
+                    top.layer.msg(d.message||'已下发');
+                    top.layer.close(index);
+                    $('#runtimeGrid').trigger('reloadGrid');
+                }, 'json');
+        }
+    });
+}
+
+// —— tab 切换:记录到 hash,刷新页面时恢复;每次切换刷新对应 grid ——
+function activateTab(h){
+    var target = (h === '#runtime') ? '#runtimePanel' : '#versionPanel';
+    $('a[data-toggle="tab"][href="'+target+'"]').tab('show');
+}
+$('a[data-toggle="tab"]').on('shown.bs.tab', function(e){
+    var href = $(e.target).attr('href');
+    location.hash = (href === '#runtimePanel') ? 'runtime' : 'version';
+    if(href === '#runtimePanel') $('#runtimeGrid').trigger('reloadGrid');
+    else $('#dataGrid').trigger('reloadGrid');
 });
-</script>
+// 页面进入时恢复
+$(function(){ if(location.hash) activateTab(location.hash); });
+</script>