瀏覽代碼

增加消息推送管理

jingbo 1 月之前
父節點
當前提交
d8635ce1cc

+ 1 - 1
scripts/backup-ui.html

@@ -267,7 +267,7 @@
     }
 
     $("btnBrowse").onclick = function () {
-      setStatus("请在弹出的系统窗口中选择文件夹(窗口可能在浏览器后面)", "run");
+      setStatus("请在最前面弹出的窗口中选择文件夹", "run");
       api("/api/browse", { method: "POST" }).then(function (j) {
         if (j.cancelled) { setStatus("", ""); return; }
         setPicked(j.path);

+ 12 - 0
src/main/java/com/jeesite/modules/mes/dao/MesDingMsgConfigDao.java

@@ -0,0 +1,12 @@
+package com.jeesite.modules.mes.dao;
+
+import com.jeesite.common.dao.CrudDao;
+import com.jeesite.common.mybatis.annotation.MyBatisDao;
+import com.jeesite.modules.mes.entity.MesDingMsgConfig;
+
+/**
+ * 钉钉消息推送配置DAO。
+ */
+@MyBatisDao
+public interface MesDingMsgConfigDao extends CrudDao<MesDingMsgConfig> {
+}

+ 12 - 0
src/main/java/com/jeesite/modules/mes/dao/MesDingMsgLineDao.java

@@ -0,0 +1,12 @@
+package com.jeesite.modules.mes.dao;
+
+import com.jeesite.common.dao.CrudDao;
+import com.jeesite.common.mybatis.annotation.MyBatisDao;
+import com.jeesite.modules.mes.entity.MesDingMsgLine;
+
+/**
+ * 钉钉消息产线接收人DAO。
+ */
+@MyBatisDao
+public interface MesDingMsgLineDao extends CrudDao<MesDingMsgLine> {
+}

+ 188 - 0
src/main/java/com/jeesite/modules/mes/dingtalk/DingMsgCard.java

@@ -0,0 +1,188 @@
+package com.jeesite.modules.mes.dingtalk;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.jeesite.common.lang.StringUtils;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 钉钉工作通知 OA 卡片内容:标题 + 若干「项目/内容」行。
+ */
+public class DingMsgCard {
+
+	private String title;
+	private List<Item> items = new ArrayList<Item>();
+
+	public static class Item {
+		private String label;
+		private String value;
+
+		public Item() {
+		}
+
+		public Item(String label, String value) {
+			this.label = label;
+			this.value = value;
+		}
+
+		public String getLabel() {
+			return label;
+		}
+
+		public void setLabel(String label) {
+			this.label = label;
+		}
+
+		public String getValue() {
+			return value;
+		}
+
+		public void setValue(String value) {
+			this.value = value;
+		}
+	}
+
+	public static DingMsgCard defaultCard() {
+		DingMsgCard card = new DingMsgCard();
+		card.setTitle("{消息名称}");
+		card.addItem("预警时间", "{发送时间}");
+		card.addItem("产线", "{产线名称}({产线编号})");
+		card.addItem("说明", "{触发说明}");
+		return card;
+	}
+
+	public static boolean isCardJson(String raw) {
+		if (StringUtils.isBlank(raw)) {
+			return false;
+		}
+		String text = raw.trim();
+		return text.startsWith("{") && text.contains("\"items\"");
+	}
+
+	public static DingMsgCard parse(String raw) {
+		if (isCardJson(raw)) {
+			try {
+				DingMsgCard card = JSON.parseObject(raw, DingMsgCard.class);
+				if (card != null) {
+					if (card.getItems() == null) {
+						card.setItems(new ArrayList<Item>());
+					}
+					return card;
+				}
+			} catch (Exception ignored) {
+				// 按旧文本解析
+			}
+		}
+		return fromLegacyText(raw);
+	}
+
+	/** 把以前的 Markdown/纯文本转成卡片,尽量按「名称:内容」拆行。 */
+	public static DingMsgCard fromLegacyText(String raw) {
+		DingMsgCard card = new DingMsgCard();
+		card.setTitle("{消息名称}");
+		if (StringUtils.isBlank(raw)) {
+			return defaultCard();
+		}
+		String[] lines = raw.replace("\r", "").split("\n");
+		for (String line : lines) {
+			String text = line.trim();
+			if (StringUtils.isBlank(text)) {
+				continue;
+			}
+			if (text.startsWith("###")) {
+				text = text.substring(3).trim();
+			}
+			if (text.startsWith("- ")) {
+				text = text.substring(2).trim();
+			}
+			int idx = text.indexOf(':');
+			if (idx < 0) {
+				idx = text.indexOf(':');
+			}
+			if (idx > 0 && idx < text.length() - 1) {
+				card.addItem(text.substring(0, idx).trim(), text.substring(idx + 1).trim());
+			}
+		}
+		if (card.getItems().isEmpty()) {
+			card.addItem("内容", raw.trim());
+		}
+		return card;
+	}
+
+	public void addItem(String label, String value) {
+		items.add(new Item(label, value));
+	}
+
+	public String toJson() {
+		return JSON.toJSONString(this);
+	}
+
+	public DingMsgCard applyPlaceholders(String msgName, String time, String lineTitle, String lineSn,
+			String userNames, String triggerDesc) {
+		DingMsgCard out = new DingMsgCard();
+		out.setTitle(replaceAll(title, msgName, time, lineTitle, lineSn, userNames, triggerDesc));
+		for (Item item : items) {
+			if (item == null) {
+				continue;
+			}
+			out.addItem(
+					replaceAll(item.getLabel(), msgName, time, lineTitle, lineSn, userNames, triggerDesc),
+					replaceAll(item.getValue(), msgName, time, lineTitle, lineSn, userNames, triggerDesc));
+		}
+		return out;
+	}
+
+	public JSONArray toOaForm() {
+		JSONArray form = new JSONArray();
+		for (Item item : items) {
+			if (item == null) {
+				continue;
+			}
+			String label = StringUtils.defaultString(item.getLabel()).trim();
+			String value = StringUtils.defaultString(item.getValue());
+			if (StringUtils.isBlank(label) && StringUtils.isBlank(value)) {
+				continue;
+			}
+			JSONObject row = new JSONObject();
+			if (StringUtils.isNotBlank(label) && !label.endsWith(":") && !label.endsWith(":")) {
+				label = label + ":";
+			}
+			row.put("key", label);
+			row.put("value", value);
+			form.add(row);
+		}
+		return form;
+	}
+
+	private static String replaceAll(String text, String msgName, String time, String lineTitle, String lineSn,
+			String userNames, String triggerDesc) {
+		if (text == null) {
+			return "";
+		}
+		return text.replace("{消息名称}", msgName).replace("${msgName}", msgName)
+				.replace("{发送时间}", time).replace("${time}", time)
+				.replace("{产线名称}", lineTitle).replace("${lineTitle}", lineTitle)
+				.replace("{产线编号}", lineSn).replace("${lineSn}", lineSn)
+				.replace("{接收人}", userNames).replace("${userNames}", userNames)
+				.replace("{触发说明}", triggerDesc);
+	}
+
+	public String getTitle() {
+		return title;
+	}
+
+	public void setTitle(String title) {
+		this.title = title;
+	}
+
+	public List<Item> getItems() {
+		return items;
+	}
+
+	public void setItems(List<Item> items) {
+		this.items = items == null ? new ArrayList<Item>() : items;
+	}
+}

+ 106 - 0
src/main/java/com/jeesite/modules/mes/dingtalk/DingMsgCode.java

@@ -0,0 +1,106 @@
+package com.jeesite.modules.mes.dingtalk;
+
+/**
+ * 钉钉消息类型(代码写死,页面不允许新增)。
+ */
+public enum DingMsgCode {
+
+	DEVICE_MAINT_L2("DEVICE", "设备", "二级保养及夹具普查提醒",
+			"每月20日任务提前3天,每天10:00和15:00各推一次", 10),
+	DEVICE_MAINT_L3("DEVICE", "设备", "三级保养到期提醒",
+			"到期前15天,每天推送一次", 20),
+	PROD_PLAN_RATE("PROD", "生产", "生产计划完成率低于97%",
+			"排产计划最后一天中午12:00检查", 30),
+	PROD_MATERIAL_3DAY("PROD", "生产", "低于3天计划量的物料",
+			"每天检查物料库存是否不足3天计划量", 40),
+	PROD_OEE_85("PROD", "生产", "设备OEE低于85%",
+			"每天低于85%时推送一次", 50),
+	PROD_STOCK_500("PROD", "生产", "成品库存低于500件",
+			"每天中午12:00检查", 60),
+	QM_FIRST_NG("QUALITY", "质量", "首件NG时推送",
+			"首件检查结果为NG时立即推送", 70),
+	QM_SPOT_NG("QUALITY", "质量", "抽检NG时推送",
+			"中间件抽检结果为NG时立即推送", 80),
+	QM_LAST_NG("QUALITY", "质量", "末件NG时推送",
+			"末件检查结果为NG时立即推送", 90),
+	QM_REPAIR_ONLINE("QUALITY", "质量", "返修完成重新上线",
+			"返修机完成并重新上线时推送", 100),
+	QM_SCRAP("QUALITY", "质量", "报废",
+			"产品报废时推送", 110),
+	QM_CMM("QUALITY", "质量", "三坐标测量",
+			"暂未对接业务,仅支持配置和测试发送", 120),
+	QM_LAB("QUALITY", "质量", "实验室实验",
+			"暂未对接业务,仅支持配置和测试发送", 130),
+	QM_NG_3("QUALITY", "质量", "当班同产品不良数超过3",
+			"当班同产品不良累计超过3件时推送", 140),
+	QM_NG_10("QUALITY", "质量", "当班同产品不良数超过10",
+			"当班同产品不良累计超过10件时推送", 150),
+	QM_NG_50("QUALITY", "质量", "当班同产品不良数超过50",
+			"当班同产品不良累计超过50件时推送", 160);
+
+	private final String module;
+	private final String moduleName;
+	private final String msgName;
+	private final String triggerDesc;
+	private final int sortOrder;
+
+	DingMsgCode(String module, String moduleName, String msgName, String triggerDesc, int sortOrder) {
+		this.module = module;
+		this.moduleName = moduleName;
+		this.msgName = msgName;
+		this.triggerDesc = triggerDesc;
+		this.sortOrder = sortOrder;
+	}
+
+	public String getCode() {
+		return name();
+	}
+
+	public String getModule() {
+		return module;
+	}
+
+	public String getModuleName() {
+		return moduleName;
+	}
+
+	public String getMsgName() {
+		return msgName;
+	}
+
+	public String getTriggerDesc() {
+		return triggerDesc;
+	}
+
+	/**
+	 * 默认发送内容:钉钉 OA 卡片(标题 + 项目行)。
+	 */
+	public String getDefaultContent() {
+		return DingMsgCard.defaultCard().toJson();
+	}
+
+	public int getSortOrder() {
+		return sortOrder;
+	}
+
+	public static DingMsgCode fromCode(String code) {
+		if (code == null) {
+			return null;
+		}
+		for (DingMsgCode item : values()) {
+			if (item.name().equals(code)) {
+				return item;
+			}
+		}
+		return null;
+	}
+
+	public static String moduleNameOf(String module) {
+		for (DingMsgCode item : values()) {
+			if (item.module.equals(module)) {
+				return item.moduleName;
+			}
+		}
+		return module;
+	}
+}

+ 75 - 13
src/main/java/com/jeesite/modules/mes/dingtalk/DingTalkAppClient.java

@@ -1,5 +1,6 @@
 package com.jeesite.modules.mes.dingtalk;
 
+import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
 import com.jeesite.common.lang.StringUtils;
 import okhttp3.HttpUrl;
@@ -40,35 +41,85 @@ public class DingTalkAppClient {
     }
 
     public boolean sendMarkdown(String title, String markdownText) {
+        return sendMarkdown(title, markdownText, null, true);
+    }
+
+    /**
+     * 按指定手机号发送工作通知,不使用 yml 全局 at-all / user-ids。
+     */
+    public boolean sendMarkdown(String title, String markdownText, List<String> mobiles) {
+        return sendMarkdown(title, markdownText, mobiles, false);
+    }
+
+    /**
+     * 按手机号发送钉钉 OA 卡片(工作通知自带的标题+表单格式)。
+     */
+    public boolean sendOa(String headText, String title, JSONArray formItems, List<String> mobiles) {
+        JSONObject head = new JSONObject();
+        head.put("bgcolor", "FF1890FF");
+        head.put("text", StringUtils.defaultString(headText, "MES通知"));
+        JSONObject body = new JSONObject();
+        body.put("title", StringUtils.defaultString(title));
+        if (formItems != null && !formItems.isEmpty()) {
+            body.put("form", formItems);
+        }
+        JSONObject oa = new JSONObject();
+        // 不设置 message_url,否则钉钉会显示可点的标题和「查看详情」
+        oa.put("head", head);
+        oa.put("body", body);
+        JSONObject msg = new JSONObject();
+        msg.put("msgtype", "oa");
+        msg.put("oa", oa);
+        return sendMessage(msg, mobiles, false);
+    }
+
+    private boolean sendMarkdown(String title, String markdownText, List<String> mobiles, boolean useGlobalReceiver) {
+        JSONObject markdown = new JSONObject();
+        markdown.put("title", title);
+        markdown.put("text", markdownText);
+        JSONObject msg = new JSONObject();
+        msg.put("msgtype", "markdown");
+        msg.put("markdown", markdown);
+        return sendMessage(msg, mobiles, useGlobalReceiver);
+    }
+
+    private boolean sendMessage(JSONObject msg, List<String> mobiles, boolean useGlobalReceiver) {
         if (!isConfigured()) {
             logger.warn("DingTalk app is disabled or app config is incomplete.");
             return false;
         }
         try {
             String token = getAccessToken();
-            List<String> userIds = getReceiverUserIds(token);
-            List<String> deptIds = split(properties.getDeptIds());
-            if (!properties.isAtAll() && userIds.isEmpty() && deptIds.isEmpty()) {
-                logger.warn("DingTalk app receiver is empty, set ding.user-ids, ding.dept-ids, ding.at-mobiles, or ding.at-all.");
-                return false;
+            List<String> userIds;
+            List<String> deptIds;
+            boolean toAllUser;
+            if (useGlobalReceiver) {
+                userIds = getReceiverUserIds(token);
+                deptIds = split(properties.getDeptIds());
+                toAllUser = properties.isAtAll();
+                if (!toAllUser && userIds.isEmpty() && deptIds.isEmpty()) {
+                    logger.warn("DingTalk app receiver is empty, set ding.user-ids, ding.dept-ids, ding.at-mobiles, or ding.at-all.");
+                    return false;
+                }
+            } else {
+                userIds = lookupUserIds(token, mobiles);
+                deptIds = java.util.Collections.emptyList();
+                toAllUser = false;
+                if (userIds.isEmpty()) {
+                    logger.warn("DingTalk app receiver is empty after mobile lookup.");
+                    return false;
+                }
             }
 
             JSONObject body = new JSONObject();
             body.put("agent_id", Long.parseLong(properties.getAgentId().trim()));
-            body.put("to_all_user", properties.isAtAll());
+            body.put("to_all_user", toAllUser);
             if (!userIds.isEmpty()) {
                 body.put("userid_list", String.join(",", userIds));
             }
             if (!deptIds.isEmpty()) {
                 body.put("dept_id_list", String.join(",", deptIds));
             }
-
-            JSONObject markdown = new JSONObject();
-            markdown.put("title", title);
-            markdown.put("text", markdownText);
-            JSONObject msg = new JSONObject();
-            msg.put("msgtype", "markdown");
-            msg.put("markdown", markdown);
             body.put("msg", msg);
             return postApi(ASYNC_SEND_URL, token, body, "DingTalk app message send");
         } catch (Exception ex) {
@@ -77,6 +128,17 @@ public class DingTalkAppClient {
         }
     }
 
+    private List<String> lookupUserIds(String token, List<String> mobiles) {
+        if (mobiles == null || mobiles.isEmpty()) {
+            return java.util.Collections.emptyList();
+        }
+        return mobiles.stream()
+                .map(mobile -> getUserIdByMobile(token, mobile))
+                .filter(StringUtils::isNotBlank)
+                .distinct()
+                .collect(Collectors.toList());
+    }
+
     private boolean isConfigured() {
         return properties.isAppEnabled()
                 && StringUtils.isNotBlank(properties.getAgentId())

+ 20 - 10
src/main/java/com/jeesite/modules/mes/dingtalk/DingTalkRobotClient.java

@@ -38,6 +38,17 @@ public class DingTalkRobotClient {
     }
 
     public boolean sendMarkdown(String title, String markdownText) {
+        return sendMarkdown(title, markdownText, getConfiguredAtMobiles(), properties.isAtAll());
+    }
+
+    /**
+     * 按指定手机号 @ 发送,不使用 yml 全局 at-all。
+     */
+    public boolean sendMarkdown(String title, String markdownText, List<String> atMobiles) {
+        return sendMarkdown(title, markdownText, atMobiles, false);
+    }
+
+    private boolean sendMarkdown(String title, String markdownText, List<String> atMobiles, boolean atAll) {
         if (!properties.isEnabled() || StringUtils.isBlank(properties.getUrl())) {
             logger.warn("DingTalk robot is disabled or webhook url is empty.");
             return false;
@@ -46,34 +57,33 @@ public class DingTalkRobotClient {
         body.put("msgtype", "markdown");
         JSONObject markdown = new JSONObject();
         markdown.put("title", title);
-        markdown.put("text", appendAtText(markdownText));
+        markdown.put("text", appendAtText(markdownText, atMobiles));
         body.put("markdown", markdown);
-        body.put("at", buildAt());
+        body.put("at", buildAt(atMobiles, atAll));
         return post(body);
     }
 
-    private JSONObject buildAt() {
+    private JSONObject buildAt(List<String> atMobiles, boolean atAll) {
         JSONObject at = new JSONObject();
-        at.put("isAtAll", properties.isAtAll());
-        List<String> mobiles = getAtMobiles();
+        at.put("isAtAll", atAll);
+        List<String> mobiles = atMobiles == null ? java.util.Collections.emptyList() : atMobiles;
         if (!mobiles.isEmpty()) {
             at.put("atMobiles", mobiles);
         }
         return at;
     }
 
-    private String appendAtText(String markdownText) {
-        List<String> mobiles = getAtMobiles();
-        if (mobiles.isEmpty()) {
+    private String appendAtText(String markdownText, List<String> atMobiles) {
+        if (atMobiles == null || atMobiles.isEmpty()) {
             return markdownText;
         }
-        String atText = mobiles.stream()
+        String atText = atMobiles.stream()
                 .map(mobile -> "@" + mobile)
                 .collect(Collectors.joining(" "));
         return markdownText + "\n\n" + atText;
     }
 
-    private List<String> getAtMobiles() {
+    private List<String> getConfiguredAtMobiles() {
         if (StringUtils.isBlank(properties.getAtMobiles())) {
             return java.util.Collections.emptyList();
         }

+ 159 - 0
src/main/java/com/jeesite/modules/mes/dingtalk/MesDingMsgTableInitializer.java

@@ -0,0 +1,159 @@
+package com.jeesite.modules.mes.dingtalk;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.boot.ApplicationArguments;
+import org.springframework.boot.ApplicationRunner;
+import org.springframework.core.annotation.Order;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Component;
+
+/**
+ * 启动时确保钉钉消息配置表、预置类型和菜单存在。
+ */
+@Component
+@Order(90)
+public class MesDingMsgTableInitializer implements ApplicationRunner {
+
+	private static final Logger logger = LoggerFactory.getLogger(MesDingMsgTableInitializer.class);
+
+	private static final String CREATE_CONFIG_SQL = "CREATE TABLE IF NOT EXISTS `mes_ding_msg_config` ("
+			+ "`id` varchar(64) NOT NULL COMMENT '主键',"
+			+ "`msg_code` varchar(64) NOT NULL COMMENT '消息编码',"
+			+ "`msg_name` varchar(100) NOT NULL COMMENT '消息名称',"
+			+ "`module` varchar(20) NOT NULL COMMENT '模块',"
+			+ "`trigger_desc` varchar(255) DEFAULT '' COMMENT '触发说明',"
+			+ "`msg_content` text COMMENT '发送内容',"
+			+ "`is_enabled` char(1) NOT NULL DEFAULT '0' COMMENT '是否启用',"
+			+ "`sort_order` int DEFAULT 0 COMMENT '排序',"
+			+ "`status` char(1) NOT NULL DEFAULT '0' 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`),"
+			+ "UNIQUE KEY `uk_mes_ding_msg_code` (`msg_code`)"
+			+ ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='钉钉消息推送配置'";
+
+	private static final String CREATE_LINE_SQL = "CREATE TABLE IF NOT EXISTS `mes_ding_msg_line` ("
+			+ "`id` varchar(64) NOT NULL COMMENT '主键',"
+			+ "`config_id` varchar(64) NOT NULL COMMENT '消息配置ID',"
+			+ "`line_sn` varchar(64) NOT NULL COMMENT '产线编号',"
+			+ "`line_title` varchar(100) DEFAULT '' COMMENT '产线名称',"
+			+ "`user_codes` varchar(2000) DEFAULT '' COMMENT '接收人用户编码',"
+			+ "`user_names` varchar(2000) DEFAULT '' COMMENT '接收人姓名',"
+			+ "`mobiles` varchar(2000) DEFAULT '' COMMENT '接收人手机号',"
+			+ "`status` char(1) NOT NULL DEFAULT '0' 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_ding_msg_line_cfg` (`config_id`, `line_sn`)"
+			+ ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='钉钉消息产线接收人'";
+
+	private final JdbcTemplate jdbcTemplate;
+
+	public MesDingMsgTableInitializer(JdbcTemplate jdbcTemplate) {
+		this.jdbcTemplate = jdbcTemplate;
+	}
+
+	@Override
+	public void run(ApplicationArguments args) {
+		try {
+			jdbcTemplate.execute(CREATE_CONFIG_SQL);
+			jdbcTemplate.execute(CREATE_LINE_SQL);
+			ensureMsgContentColumn();
+			seedConfigs();
+			ensureMenu();
+			logger.info("钉钉消息推送配置表已就绪");
+		} catch (Exception ex) {
+			logger.error("初始化钉钉消息推送配置失败,请手动执行 db/mysql/add_mes_ding_msg.sql: {}",
+					ex.getMessage(), ex);
+		}
+	}
+
+	private void seedConfigs() {
+		for (DingMsgCode code : DingMsgCode.values()) {
+			Integer exists = jdbcTemplate.queryForObject(
+					"SELECT COUNT(1) FROM mes_ding_msg_config WHERE msg_code = ?", Integer.class, code.getCode());
+			if (exists != null && exists > 0) {
+				jdbcTemplate.update(
+						"UPDATE mes_ding_msg_config SET msg_content = ? WHERE msg_code = ? AND (msg_content IS NULL OR msg_content = '')",
+						code.getDefaultContent(), code.getCode());
+				jdbcTemplate.update(
+						"UPDATE mes_ding_msg_config SET msg_content = REPLACE(REPLACE(REPLACE(REPLACE(REPLACE("
+								+ "msg_content, '${msgName}', '{消息名称}'), '${time}', '{发送时间}'), "
+								+ "'${lineTitle}', '{产线名称}'), '${lineSn}', '{产线编号}'), '${userNames}', '{接收人}') "
+								+ "WHERE msg_code = ? AND msg_content LIKE '%${%'",
+						code.getCode());
+				String current = jdbcTemplate.queryForObject(
+						"SELECT msg_content FROM mes_ding_msg_config WHERE msg_code = ?", String.class, code.getCode());
+				if (!DingMsgCard.isCardJson(current)) {
+					jdbcTemplate.update("UPDATE mes_ding_msg_config SET msg_content = ? WHERE msg_code = ?",
+							DingMsgCard.parse(current).toJson(), code.getCode());
+				}
+				continue;
+			}
+			jdbcTemplate.update(
+					"INSERT INTO mes_ding_msg_config (id, msg_code, msg_name, module, trigger_desc, msg_content, is_enabled, sort_order, status, create_by, create_date, update_by, update_date) "
+							+ "VALUES (?, ?, ?, ?, ?, ?, '0', ?, '0', 'system', NOW(), 'system', NOW())",
+					code.getCode(), code.getCode(), code.getMsgName(), code.getModule(),
+					code.getTriggerDesc(), code.getDefaultContent(), code.getSortOrder());
+		}
+	}
+
+	/** 已有表补加发送内容列 */
+	private void ensureMsgContentColumn() {
+		Integer cnt = jdbcTemplate.queryForObject(
+				"SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mes_ding_msg_config' AND COLUMN_NAME = 'msg_content'",
+				Integer.class);
+		if (cnt == null || cnt == 0) {
+			jdbcTemplate.execute("ALTER TABLE mes_ding_msg_config ADD COLUMN msg_content text COMMENT '发送内容' AFTER trigger_desc");
+		}
+	}
+
+	private void ensureMenu() {
+		Integer count = jdbcTemplate.queryForObject(
+				"SELECT COUNT(1) FROM js_sys_menu WHERE menu_code = ?", Integer.class, "mesDingMsgConfig");
+		if (count == null || count == 0) {
+			jdbcTemplate.update(
+					"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 ("
+							+ "'mesDingMsgConfig', '1582259828007895040', '0,1582259822626603008,1582259828007895040,', "
+							+ "180, '0000009000,0000000500,0000000180,', '1', 2, "
+							+ "'系统管理/系统设置/钉钉消息推送', '钉钉消息推送', '1', '/mes/mesDingMsgConfig/list', '', "
+							+ "'icon-speech', '', '', 'mes:mesDingMsgConfig:view', 40, '1', 'default', 'core', NULL, NULL, "
+							+ "'0', 'system', NOW(), 'system', NOW(), '')");
+			jdbcTemplate.update(
+					"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 ("
+							+ "'mesDingMsgConfigEdit', 'mesDingMsgConfig', '0,1582259822626603008,1582259828007895040,mesDingMsgConfig,', "
+							+ "30, '0000009000,0000000500,0000000180,0000000030,', '1', 3, "
+							+ "'系统管理/系统设置/钉钉消息推送/编辑', '编辑', '2', '', '', "
+							+ "'', '', '', 'mes:mesDingMsgConfig:edit', 40, '0', 'default', 'core', NULL, NULL, "
+							+ "'0', 'system', NOW(), 'system', NOW(), '')");
+		}
+		grantRoleMenu("corpAdmin", "mesDingMsgConfig");
+		grantRoleMenu("corpAdmin", "mesDingMsgConfigEdit");
+	}
+
+	private void grantRoleMenu(String roleCode, String menuCode) {
+		Integer roleCount = jdbcTemplate.queryForObject(
+				"SELECT COUNT(1) FROM js_sys_role_menu WHERE role_code = ? AND menu_code = ?",
+				Integer.class, roleCode, menuCode);
+		if (roleCount == null || roleCount == 0) {
+			jdbcTemplate.update("INSERT INTO js_sys_role_menu (role_code, menu_code) VALUES (?, ?)",
+					roleCode, menuCode);
+		}
+	}
+}
+

+ 76 - 0
src/main/java/com/jeesite/modules/mes/dingtalk/MesDingTalkAlertService.java

@@ -2,11 +2,15 @@ package com.jeesite.modules.mes.dingtalk;
 
 import com.jeesite.common.lang.DateUtils;
 import com.jeesite.common.lang.StringUtils;
+import com.jeesite.common.service.ServiceException;
 import com.jeesite.modules.mes.entity.*;
 import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Service;
 
 import java.util.Date;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
 
 @Service
 public class MesDingTalkAlertService {
@@ -19,6 +23,78 @@ public class MesDingTalkAlertService {
         this.appClient = appClient;
     }
 
+    /**
+     * 测试发送:不校验启用开关,按指定产线接收人立即推送。
+     */
+    public String sendTest(MesDingMsgConfig config, MesDingMsgLine line) {
+        List<String> mobiles = splitMobiles(line.getMobiles());
+        if (mobiles.isEmpty()) {
+            throw new ServiceException("该产线没有有效手机号");
+        }
+        DingMsgCard card = renderCard(config, line);
+        boolean appOk = appClient.sendOa("MES通知", card.getTitle(), card.toOaForm(), mobiles);
+        if (appOk) {
+            return "测试发送完成:已向接收人推送工作通知";
+        }
+        throw new ServiceException("测试发送失败,请检查钉钉应用配置和接收人手机号");
+    }
+
+    /**
+     * 把配置渲染成钉钉 OA 卡片(占位符换成实际值)。
+     */
+    public DingMsgCard renderCard(MesDingMsgConfig config, MesDingMsgLine line) {
+        String content = config == null ? "" : config.getMsgContent();
+        DingMsgCard card = DingMsgCard.parse(content);
+        if (card.getItems().isEmpty() && config != null) {
+            DingMsgCode code = DingMsgCode.fromCode(config.getMsgCode());
+            if (code != null) {
+                card = DingMsgCard.parse(code.getDefaultContent());
+            }
+        }
+        String lineTitle = line == null ? "" : StringUtils.defaultString(line.getLineTitle());
+        String lineSn = line == null ? "" : StringUtils.defaultString(line.getLineSn());
+        String userNames = line == null ? "" : StringUtils.defaultString(line.getUserNames());
+        String msgName = config == null ? "" : StringUtils.defaultString(config.getMsgName());
+        String triggerDesc = config == null ? "" : StringUtils.defaultString(config.getTriggerDesc());
+        String time = DateUtils.formatDateTime(new Date());
+        return card.applyPlaceholders(msgName, time, lineTitle, lineSn, userNames, triggerDesc);
+    }
+
+    /**
+     * 兼容旧调用:仍返回纯文本。新的配置页发送走 OA 卡片。
+     */
+    public String renderContent(MesDingMsgConfig config, MesDingMsgLine line, boolean test) {
+        DingMsgCard card = renderCard(config, line);
+        StringBuilder text = new StringBuilder();
+        if (StringUtils.isNotBlank(card.getTitle())) {
+            text.append(card.getTitle()).append("\n");
+        }
+        if (card.getItems() != null) {
+            for (DingMsgCard.Item item : card.getItems()) {
+                if (item == null) {
+                    continue;
+                }
+                text.append(StringUtils.defaultString(item.getLabel()));
+                if (StringUtils.isNotBlank(item.getLabel())) {
+                    text.append(":");
+                }
+                text.append(StringUtils.defaultString(item.getValue())).append("\n");
+            }
+        }
+        return text.toString();
+    }
+
+    private List<String> splitMobiles(String mobiles) {
+        if (StringUtils.isBlank(mobiles)) {
+            return java.util.Collections.emptyList();
+        }
+        return Stream.of(mobiles.split(","))
+                .map(String::trim)
+                .filter(StringUtils::isNotBlank)
+                .distinct()
+                .collect(Collectors.toList());
+    }
+
     @Async
     public void sendProductionAlarm(MesProcessAlarm alarm) {
         sendMarkdown("生产异常预警", buildMessage("生产异常预警",

+ 133 - 0
src/main/java/com/jeesite/modules/mes/entity/MesDingMsgConfig.java

@@ -0,0 +1,133 @@
+package com.jeesite.modules.mes.entity;
+
+import com.jeesite.common.collect.ListUtils;
+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.List;
+
+/**
+ * 钉钉消息推送配置(类型预置,页面只允许启停、改发送内容和配人)。
+ */
+@Table(name = "mes_ding_msg_config", alias = "a", label = "钉钉消息推送配置", columns = {
+		@Column(name = "id", attrName = "id", label = "主键", isPK = true),
+		@Column(name = "msg_code", attrName = "msgCode", label = "消息编码", isQuery = true, queryType = QueryType.EQ),
+		@Column(name = "msg_name", attrName = "msgName", label = "消息名称", isQuery = true, queryType = QueryType.LIKE),
+		@Column(name = "module", attrName = "module", label = "模块", isQuery = true, queryType = QueryType.EQ),
+		@Column(name = "trigger_desc", attrName = "triggerDesc", label = "触发说明"),
+		@Column(name = "msg_content", attrName = "msgContent", label = "发送内容"),
+		@Column(name = "is_enabled", attrName = "isEnabled", label = "是否启用", isQuery = true),
+		@Column(name = "sort_order", attrName = "sortOrder", label = "排序"),
+		@Column(name = "status", attrName = "status", label = "状态", isUpdate = false),
+		@Column(name = "create_by", attrName = "createBy", label = "创建人", isUpdate = false, isQuery = false),
+		@Column(name = "create_date", attrName = "createDate", label = "创建日期", isUpdate = false, isQuery = false, isUpdateForce = true),
+		@Column(name = "update_by", attrName = "updateBy", label = "更新人", isQuery = false),
+		@Column(name = "update_date", attrName = "updateDate", label = "更新日期", isQuery = false, isUpdateForce = true),
+		@Column(name = "remarks", attrName = "remarks", label = "备注", queryType = QueryType.LIKE),
+}, orderBy = "a.sort_order ASC, a.msg_code ASC")
+public class MesDingMsgConfig extends DataEntity<MesDingMsgConfig> {
+
+	private static final long serialVersionUID = 1L;
+
+	private String msgCode;
+	private String msgName;
+	private String module;
+	private String triggerDesc;
+	private String msgContent;
+	private String isEnabled;
+	private Integer sortOrder;
+	private String remarks;
+	/** 已配置产线摘要(非表字段) */
+	private String lineSummary;
+	private List<MesDingMsgLine> mesDingMsgLineList = ListUtils.newArrayList();
+
+	public MesDingMsgConfig() {
+		this(null);
+	}
+
+	public MesDingMsgConfig(String id) {
+		super(id);
+	}
+
+	public String getMsgCode() {
+		return msgCode;
+	}
+
+	public void setMsgCode(String msgCode) {
+		this.msgCode = msgCode;
+	}
+
+	public String getMsgName() {
+		return msgName;
+	}
+
+	public void setMsgName(String msgName) {
+		this.msgName = msgName;
+	}
+
+	public String getModule() {
+		return module;
+	}
+
+	public void setModule(String module) {
+		this.module = module;
+	}
+
+	public String getTriggerDesc() {
+		return triggerDesc;
+	}
+
+	public void setTriggerDesc(String triggerDesc) {
+		this.triggerDesc = triggerDesc;
+	}
+
+	public String getMsgContent() {
+		return msgContent;
+	}
+
+	public void setMsgContent(String msgContent) {
+		this.msgContent = msgContent;
+	}
+
+	public String getIsEnabled() {
+		return isEnabled;
+	}
+
+	public void setIsEnabled(String isEnabled) {
+		this.isEnabled = isEnabled;
+	}
+
+	public Integer getSortOrder() {
+		return sortOrder;
+	}
+
+	public void setSortOrder(Integer sortOrder) {
+		this.sortOrder = sortOrder;
+	}
+
+	public String getRemarks() {
+		return remarks;
+	}
+
+	public void setRemarks(String remarks) {
+		this.remarks = remarks;
+	}
+
+	public String getLineSummary() {
+		return lineSummary;
+	}
+
+	public void setLineSummary(String lineSummary) {
+		this.lineSummary = lineSummary;
+	}
+
+	public List<MesDingMsgLine> getMesDingMsgLineList() {
+		return mesDingMsgLineList;
+	}
+
+	public void setMesDingMsgLineList(List<MesDingMsgLine> mesDingMsgLineList) {
+		this.mesDingMsgLineList = mesDingMsgLineList;
+	}
+}

+ 91 - 0
src/main/java/com/jeesite/modules/mes/entity/MesDingMsgLine.java

@@ -0,0 +1,91 @@
+package com.jeesite.modules.mes.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;
+
+/**
+ * 钉钉消息按产线接收人配置。
+ */
+@Table(name = "mes_ding_msg_line", alias = "a", label = "钉钉消息产线接收人", columns = {
+		@Column(name = "id", attrName = "id", label = "主键", isPK = true),
+		@Column(name = "config_id", attrName = "configId", label = "消息配置ID"),
+		@Column(name = "line_sn", attrName = "lineSn", label = "产线编号", queryType = QueryType.EQ),
+		@Column(name = "line_title", attrName = "lineTitle", label = "产线名称"),
+		@Column(name = "user_codes", attrName = "userCodes", label = "接收人编码"),
+		@Column(name = "user_names", attrName = "userNames", label = "接收人姓名"),
+		@Column(name = "mobiles", attrName = "mobiles", label = "接收人手机号"),
+		@Column(name = "status", attrName = "status", label = "状态", isUpdate = false),
+		@Column(name = "create_by", attrName = "createBy", label = "创建人", isUpdate = false, isQuery = false),
+		@Column(name = "create_date", attrName = "createDate", label = "创建日期", isUpdate = false, isQuery = false, isUpdateForce = true),
+		@Column(name = "update_by", attrName = "updateBy", label = "更新人", isQuery = false),
+		@Column(name = "update_date", attrName = "updateDate", label = "更新日期", isQuery = false, isUpdateForce = true),
+}, orderBy = "a.line_sn ASC")
+public class MesDingMsgLine extends DataEntity<MesDingMsgLine> {
+
+	private static final long serialVersionUID = 1L;
+
+	private String configId;
+	private String lineSn;
+	private String lineTitle;
+	private String userCodes;
+	private String userNames;
+	private String mobiles;
+
+	public MesDingMsgLine() {
+		this(null);
+	}
+
+	public MesDingMsgLine(String id) {
+		super(id);
+	}
+
+	public String getConfigId() {
+		return configId;
+	}
+
+	public void setConfigId(String configId) {
+		this.configId = configId;
+	}
+
+	public String getLineSn() {
+		return lineSn;
+	}
+
+	public void setLineSn(String lineSn) {
+		this.lineSn = lineSn;
+	}
+
+	public String getLineTitle() {
+		return lineTitle;
+	}
+
+	public void setLineTitle(String lineTitle) {
+		this.lineTitle = lineTitle;
+	}
+
+	public String getUserCodes() {
+		return userCodes;
+	}
+
+	public void setUserCodes(String userCodes) {
+		this.userCodes = userCodes;
+	}
+
+	public String getUserNames() {
+		return userNames;
+	}
+
+	public void setUserNames(String userNames) {
+		this.userNames = userNames;
+	}
+
+	public String getMobiles() {
+		return mobiles;
+	}
+
+	public void setMobiles(String mobiles) {
+		this.mobiles = mobiles;
+	}
+}

+ 234 - 0
src/main/java/com/jeesite/modules/mes/service/MesDingMsgConfigService.java

@@ -0,0 +1,234 @@
+package com.jeesite.modules.mes.service;
+
+import com.jeesite.common.collect.ListUtils;
+import com.jeesite.common.entity.Page;
+import com.jeesite.common.lang.StringUtils;
+import com.jeesite.common.service.CrudService;
+import com.jeesite.common.service.ServiceException;
+import com.jeesite.modules.mes.dao.MesDingMsgConfigDao;
+import com.jeesite.modules.mes.dao.MesDingMsgLineDao;
+import com.jeesite.modules.mes.dao.MesLineDao;
+import com.jeesite.modules.mes.entity.MesDingMsgConfig;
+import com.jeesite.modules.mes.entity.MesDingMsgLine;
+import com.jeesite.modules.mes.entity.MesLine;
+import com.jeesite.modules.sys.entity.User;
+import com.jeesite.modules.sys.utils.UserUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * 钉钉消息推送配置 Service。
+ */
+@Service
+public class MesDingMsgConfigService extends CrudService<MesDingMsgConfigDao, MesDingMsgConfig> {
+
+	@Autowired
+	private MesDingMsgLineDao mesDingMsgLineDao;
+	@Autowired
+	private MesLineDao mesLineDao;
+
+	@Override
+	public MesDingMsgConfig get(MesDingMsgConfig mesDingMsgConfig) {
+		MesDingMsgConfig entity = super.get(mesDingMsgConfig);
+		if (entity != null) {
+			entity.setMesDingMsgLineList(findLineList(entity.getId()));
+			fillLineSummary(entity);
+		}
+		return entity;
+	}
+
+	@Override
+	public Page<MesDingMsgConfig> findPage(MesDingMsgConfig mesDingMsgConfig) {
+		Page<MesDingMsgConfig> page = super.findPage(mesDingMsgConfig);
+		if (page.getList() != null) {
+			for (MesDingMsgConfig item : page.getList()) {
+				item.setMesDingMsgLineList(findLineList(item.getId()));
+				fillLineSummary(item);
+			}
+		}
+		return page;
+	}
+
+	@Override
+	@Transactional
+	public void save(MesDingMsgConfig mesDingMsgConfig) {
+		super.save(mesDingMsgConfig);
+		saveLineList(mesDingMsgConfig);
+	}
+
+	/**
+	 * 只更新启用状态,不改产线接收人。
+	 */
+	@Transactional
+	public void updateEnabled(String id, String isEnabled) {
+		MesDingMsgConfig db = super.get(new MesDingMsgConfig(id));
+		if (db == null) {
+			throw new ServiceException("消息配置不存在");
+		}
+		db.setIsEnabled("1".equals(isEnabled) ? "1" : "0");
+		super.save(db);
+	}
+
+	public MesDingMsgConfig getByMsgCode(String msgCode) {
+		if (StringUtils.isBlank(msgCode)) {
+			return null;
+		}
+		MesDingMsgConfig query = new MesDingMsgConfig();
+		query.setMsgCode(msgCode);
+		List<MesDingMsgConfig> list = super.findList(query);
+		if (list == null || list.isEmpty()) {
+			return null;
+		}
+		return get(list.get(0));
+	}
+
+	public boolean isEnabled(String msgCode) {
+		MesDingMsgConfig query = new MesDingMsgConfig();
+		query.setMsgCode(msgCode);
+		List<MesDingMsgConfig> list = super.findList(query);
+		if (list == null || list.isEmpty()) {
+			return false;
+		}
+		return "1".equals(list.get(0).getIsEnabled());
+	}
+
+	public MesDingMsgLine findLine(String configId, String lineSn) {
+		if (StringUtils.isBlank(configId) || StringUtils.isBlank(lineSn)) {
+			return null;
+		}
+		MesDingMsgLine query = new MesDingMsgLine();
+		query.setConfigId(configId);
+		query.setLineSn(lineSn);
+		query.setStatus(MesDingMsgLine.STATUS_NORMAL);
+		List<MesDingMsgLine> list = mesDingMsgLineDao.findList(query);
+		return ListUtils.isEmpty(list) ? null : list.get(0);
+	}
+
+	private List<MesDingMsgLine> findLineList(String configId) {
+		if (StringUtils.isBlank(configId)) {
+			return ListUtils.newArrayList();
+		}
+		MesDingMsgLine query = new MesDingMsgLine();
+		query.setConfigId(configId);
+		query.setStatus(MesDingMsgLine.STATUS_NORMAL);
+		return mesDingMsgLineDao.findList(query);
+	}
+
+	private void saveLineList(MesDingMsgConfig mesDingMsgConfig) {
+		List<MesDingMsgLine> lineList = mesDingMsgConfig.getMesDingMsgLineList();
+		if (lineList == null) {
+			return;
+		}
+		Set<String> usedLineSn = new LinkedHashSet<>();
+		for (MesDingMsgLine line : lineList) {
+			if (MesDingMsgLine.STATUS_DELETE.equals(line.getStatus())) {
+				if (StringUtils.isNotBlank(line.getId())) {
+					mesDingMsgLineDao.delete(line);
+				}
+				continue;
+			}
+			fillLineReceivers(line);
+			if (usedLineSn.contains(line.getLineSn())) {
+				throw new ServiceException("同一消息下产线不能重复:" + line.getLineSn());
+			}
+			usedLineSn.add(line.getLineSn());
+			line.setConfigId(mesDingMsgConfig.getId());
+			if (line.getIsNewRecord()) {
+				mesDingMsgLineDao.insert(line);
+			} else {
+				mesDingMsgLineDao.update(line);
+			}
+		}
+	}
+
+	private void fillLineReceivers(MesDingMsgLine line) {
+		if (StringUtils.isBlank(line.getLineSn())) {
+			throw new ServiceException("请选择产线");
+		}
+		MesLine mesLine = mesLineDao.selectIdBysn(line.getLineSn());
+		if (mesLine == null) {
+			throw new ServiceException("产线不存在:" + line.getLineSn());
+		}
+		line.setLineTitle(mesLine.getTitle());
+
+		List<User> users = resolveUsers(line.getUserCodes());
+		if (users.isEmpty()) {
+			throw new ServiceException("请为产线【" + mesLine.getTitle() + "】选择接收人");
+		}
+		List<String> codes = new ArrayList<>();
+		List<String> names = new ArrayList<>();
+		List<String> mobiles = new ArrayList<>();
+		for (User user : users) {
+			String mobile = firstMobile(user);
+			if (StringUtils.isBlank(mobile)) {
+				throw new ServiceException("用户【" + user.getUserName() + "】未维护手机号,不能作为钉钉接收人");
+			}
+			codes.add(user.getUserCode());
+			names.add(user.getUserName());
+			mobiles.add(mobile);
+		}
+		line.setUserCodes(String.join(",", codes));
+		line.setUserNames(String.join(",", names));
+		line.setMobiles(String.join(",", mobiles));
+	}
+
+	private List<User> resolveUsers(String userCodeStr) {
+		List<User> result = new ArrayList<>();
+		Set<String> seen = new LinkedHashSet<>();
+		String[] userCodes = StringUtils.split(StringUtils.trim(userCodeStr), ",");
+		if (userCodes == null) {
+			return result;
+		}
+		for (String code : userCodes) {
+			String userCode = StringUtils.trim(code);
+			if (StringUtils.isBlank(userCode)) {
+				continue;
+			}
+			User user = UserUtils.get(userCode);
+			if (user == null || StringUtils.isBlank(user.getUserCode())) {
+				continue;
+			}
+			if (seen.add(user.getUserCode())) {
+				result.add(user);
+			}
+		}
+		return result;
+	}
+
+	private String firstMobile(User user) {
+		if (user == null) {
+			return "";
+		}
+		if (StringUtils.isNotBlank(user.getMobile())) {
+			return user.getMobile().trim();
+		}
+		if (StringUtils.isNotBlank(user.getPhone())) {
+			return user.getPhone().trim();
+		}
+		return "";
+	}
+
+	private void fillLineSummary(MesDingMsgConfig config) {
+		List<MesDingMsgLine> list = config.getMesDingMsgLineList();
+		if (ListUtils.isEmpty(list)) {
+			config.setLineSummary("未配置产线");
+			return;
+		}
+		List<String> parts = new ArrayList<>();
+		for (MesDingMsgLine line : list) {
+			int userCount = 0;
+			if (StringUtils.isNotBlank(line.getUserCodes())) {
+				userCount = StringUtils.split(line.getUserCodes(), ",").length;
+			}
+			String title = StringUtils.defaultIfBlank(line.getLineTitle(), line.getLineSn());
+			parts.add(title + "(" + userCount + "人)");
+		}
+		config.setLineSummary(String.join("、", parts));
+	}
+}

+ 164 - 0
src/main/java/com/jeesite/modules/mes/web/MesDingMsgConfigController.java

@@ -0,0 +1,164 @@
+package com.jeesite.modules.mes.web;
+
+import com.jeesite.common.collect.ListUtils;
+import com.jeesite.common.collect.MapUtils;
+import com.jeesite.common.config.Global;
+import com.jeesite.common.entity.Page;
+import com.jeesite.common.lang.StringUtils;
+import com.jeesite.common.service.ServiceException;
+import com.jeesite.common.web.BaseController;
+import com.jeesite.modules.mes.dingtalk.DingMsgCard;
+import com.jeesite.modules.mes.dingtalk.DingMsgCode;
+import com.jeesite.modules.mes.dingtalk.MesDingTalkAlertService;
+import com.jeesite.modules.mes.entity.MesDingMsgConfig;
+import com.jeesite.modules.mes.entity.MesDingMsgLine;
+import com.jeesite.modules.mes.entity.MesLine;
+import com.jeesite.modules.mes.service.MesDingMsgConfigService;
+import com.jeesite.modules.mes.service.MesLineService;
+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.ModelAttribute;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.ResponseBody;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 钉钉消息推送配置 Controller。类型预置,页面只允许查看、启停、按产线配人和测试发送。
+ */
+@Controller
+@RequestMapping(value = "${adminPath}/mes/mesDingMsgConfig")
+public class MesDingMsgConfigController extends BaseController {
+
+	@Autowired
+	private MesDingMsgConfigService mesDingMsgConfigService;
+	@Autowired
+	private MesLineService mesLineService;
+	@Autowired
+	private MesDingTalkAlertService mesDingTalkAlertService;
+
+	@ModelAttribute
+	public MesDingMsgConfig get(String id, boolean isNewRecord) {
+		return mesDingMsgConfigService.get(id, isNewRecord);
+	}
+
+	@RequiresPermissions("mes:mesDingMsgConfig:view")
+	@RequestMapping(value = {"list", ""})
+	public String list(MesDingMsgConfig mesDingMsgConfig, Model model) {
+		model.addAttribute("mesDingMsgConfig", mesDingMsgConfig);
+		return "modules/mes/mesDingMsgConfigList";
+	}
+
+	@RequiresPermissions("mes:mesDingMsgConfig:view")
+	@RequestMapping(value = "listData")
+	@ResponseBody
+	public Page<MesDingMsgConfig> listData(MesDingMsgConfig mesDingMsgConfig,
+			HttpServletRequest request, HttpServletResponse response) {
+		mesDingMsgConfig.setPage(new Page<>(request, response));
+		return mesDingMsgConfigService.findPage(mesDingMsgConfig);
+	}
+
+	@RequiresPermissions("mes:mesDingMsgConfig:view")
+	@RequestMapping(value = "form")
+	public String form(MesDingMsgConfig mesDingMsgConfig, Model model) {
+		if (mesDingMsgConfig == null || StringUtils.isBlank(mesDingMsgConfig.getId())
+				|| mesDingMsgConfig.getIsNewRecord()) {
+			model.addAttribute("mesDingMsgConfig", new MesDingMsgConfig());
+			return "modules/mes/mesDingMsgConfigList";
+		}
+		DingMsgCode code = DingMsgCode.fromCode(mesDingMsgConfig.getMsgCode());
+		String defaultContent = code == null ? DingMsgCard.defaultCard().toJson() : code.getDefaultContent();
+		if (StringUtils.isBlank(mesDingMsgConfig.getMsgContent())) {
+			mesDingMsgConfig.setMsgContent(defaultContent);
+		}
+		DingMsgCard card = DingMsgCard.parse(mesDingMsgConfig.getMsgContent());
+		mesDingMsgConfig.setMsgContent(card.toJson());
+		model.addAttribute("mesDingMsgConfig", mesDingMsgConfig);
+		model.addAttribute("defaultContent", defaultContent);
+		model.addAttribute("msgCard", card);
+		model.addAttribute("moduleName", DingMsgCode.moduleNameOf(mesDingMsgConfig.getModule()));
+		model.addAttribute("lineOptions", buildLineOptions());
+		return "modules/mes/mesDingMsgConfigForm";
+	}
+
+	@RequiresPermissions("mes:mesDingMsgConfig:edit")
+	@PostMapping(value = "save")
+	@ResponseBody
+	public String save(MesDingMsgConfig mesDingMsgConfig) {
+		if (mesDingMsgConfig == null || StringUtils.isBlank(mesDingMsgConfig.getId())) {
+			return renderResult(Global.FALSE, text("不允许新增消息类型"));
+		}
+		MesDingMsgConfig db = mesDingMsgConfigService.get(mesDingMsgConfig.getId());
+		if (db == null || db.getIsNewRecord()) {
+			return renderResult(Global.FALSE, text("消息配置不存在"));
+		}
+		try {
+			db.setIsEnabled("1".equals(mesDingMsgConfig.getIsEnabled()) ? "1" : "0");
+			db.setMsgContent(mesDingMsgConfig.getMsgContent());
+			db.setRemarks(mesDingMsgConfig.getRemarks());
+			db.setMesDingMsgLineList(mesDingMsgConfig.getMesDingMsgLineList());
+			mesDingMsgConfigService.save(db);
+			return renderResult(Global.TRUE, text("保存钉钉消息配置成功!"));
+		} catch (ServiceException ex) {
+			return renderResult(Global.FALSE, text(ex.getMessage()));
+		}
+	}
+
+	@RequiresPermissions("mes:mesDingMsgConfig:edit")
+	@PostMapping(value = "enable")
+	@ResponseBody
+	public String enable(String id, String isEnabled) {
+		try {
+			mesDingMsgConfigService.updateEnabled(id, isEnabled);
+			return renderResult(Global.TRUE, text("1".equals(isEnabled) ? "已启用" : "已停用"));
+		} catch (ServiceException ex) {
+			return renderResult(Global.FALSE, text(ex.getMessage()));
+		}
+	}
+
+	@RequiresPermissions("mes:mesDingMsgConfig:edit")
+	@PostMapping(value = "testSend")
+	@ResponseBody
+	public String testSend(String id, String lineSn) {
+		MesDingMsgConfig config = mesDingMsgConfigService.get(id);
+		if (config == null) {
+			return renderResult(Global.FALSE, text("消息配置不存在"));
+		}
+		if (StringUtils.isBlank(lineSn)) {
+			return renderResult(Global.FALSE, text("请选择要测试的产线"));
+		}
+		MesDingMsgLine line = mesDingMsgConfigService.findLine(config.getId(), lineSn);
+		if (line == null) {
+			return renderResult(Global.FALSE, text("该产线尚未配置接收人"));
+		}
+		try {
+			return renderResult(Global.TRUE, text(mesDingTalkAlertService.sendTest(config, line)));
+		} catch (ServiceException ex) {
+			return renderResult(Global.FALSE, text(ex.getMessage()));
+		}
+	}
+
+	private List<Map<String, String>> buildLineOptions() {
+		List<Map<String, String>> options = ListUtils.newArrayList();
+		List<MesLine> lines = mesLineService.findList(new MesLine());
+		if (lines == null) {
+			return options;
+		}
+		for (MesLine line : lines) {
+			if (line == null || StringUtils.isBlank(line.getSn())) {
+				continue;
+			}
+			Map<String, String> item = MapUtils.newHashMap();
+			item.put("id", line.getSn());
+			item.put("title", StringUtils.defaultString(line.getTitle()) + "(" + line.getSn() + ")");
+			options.add(item);
+		}
+		return options;
+	}
+}

+ 23 - 16
src/main/resources/config/application.yml

@@ -25,10 +25,16 @@ ding:
   at-mobiles: 19120501982
   at-all: false
   app-enabled: true
-  app-id: 2372e61f-33b9-436c-b174-f58aa3049759
-  agent-id: 4724073992
-  client-id: dingpr9lhefdgk79mhqx
-  client-secret: 18ClEucl6Fg2w_HwUGtqSgTiSmA1S_7Qee9GLp63xlVQPAQKWwB93A0QcEBidKOQ
+  # 生产环境
+  # app-id: 2372e61f-33b9-436c-b174-f58aa3049759
+  # agent-id: 4724073992
+  # client-id: dingpr9lhefdgk79mhqx
+  # client-secret: 18ClEucl6Fg2w_HwUGtqSgTiSmA1S_7Qee9GLp63xlVQPAQKWwB93A0QcEBidKOQ
+  # 测试环境(功能测试 / 消息通知)
+  app-id: 4b2ac723-d8a8-4a7a-b66d-24ddfd70e19e
+  agent-id: 4929082616
+  client-id: ding7sjxdqmh6fh8dgd8
+  client-secret: zbYCChx_-QlPWz-EleDUjt-2ISo-dtj-4ZRBDRQWTz43hnJm3SK__ArWqPEkDx6D
   user-ids:
   dept-ids:
 
@@ -52,12 +58,13 @@ erp:
   k3cloud:
     base-url: https://erp.anhuidayang.com:8800/k3cloud
 #    生产环境
-    acct-id: 672630265043d5
+#    username: 经博
+#    acct-id: 672630265043d5
+#    password: JingBo@030323
 #    测试环境
-#    acct-id: 6a239b82159033
+    acct-id: 6a239b82159033
     username: 经博
-    password: JingBo@030323
-#    password: dy123456.
+    password: dy123456.
     org-number: 110
     filter-current-org: true
     inventory-category-filter-enabled: true
@@ -170,12 +177,12 @@ jdbc:
   driver: com.mysql.cj.jdbc.Driver
  # url: jdbc:mysql://127.0.0.1:3306/ce_liu?useSSL=false&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=CONVERT_TO_NULL&serverTimezone=Asia/Shanghai&nullCatalogMeansCurrent=true
 #  url: jdbc:mysql://127.0.0.1:3306/mes_cloude_npd?useSSL=false&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=CONVERT_TO_NULL&serverTimezone=Asia/Shanghai&nullCatalogMeansCurrent=true
-#  url: jdbc:mysql://127.0.0.1:3306/mes_cloud_ahdy?useSSL=false&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=CONVERT_TO_NULL&serverTimezone=Asia/Shanghai&nullCatalogMeansCurrent=true
-  url: jdbc:mysql://192.168.9.181:3306/mes_cloud?useSSL=false&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=CONVERT_TO_NULL&serverTimezone=Asia/Shanghai&nullCatalogMeansCurrent=true
-  username: mes_cloud
-  password: s2ENj7KaEySTC7dW
-#  username: root
-#  password: 123456
+  url: jdbc:mysql://127.0.0.1:3306/mes_cloud_dy?useSSL=false&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=CONVERT_TO_NULL&serverTimezone=Asia/Shanghai&nullCatalogMeansCurrent=true
+#  url: jdbc:mysql://192.168.9.181:3306/mes_cloud?useSSL=false&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=CONVERT_TO_NULL&serverTimezone=Asia/Shanghai&nullCatalogMeansCurrent=true
+#  username: mes_cloud
+#  password: s2ENj7KaEySTC7dW
+  username: root
+  password: root
   testSql: SELECT 1
 
   #  # Oracle 数据库配置(若使用 12c,请修改 /modules/core/pom.xml 文件,打开 12c 依赖,去掉 11g 依赖)
@@ -1081,9 +1088,9 @@ file:
 
   #  # 文件上传根路径,设置路径中不允许包含“userfiles”,在指定目录中系统会自动创建userfiles目录,如果不设置默认为contextPath路径
   # 服务器
-  baseDir: E:/mescloud
+#  baseDir: E:/mescloud
   # 本地
-#  baseDir: D:/mescloud
+  baseDir: D:/mescloud
   #  baseDir: /www/wwwroot/mes_cloud_dy
   #
   #  # 上传文件的相对路径(支持:yyyy, MM, dd, HH, mm, ss, E)

+ 288 - 0
src/main/resources/views/modules/mes/mesDingMsgConfigForm.html

@@ -0,0 +1,288 @@
+<% layout('/layouts/default.html', {title: '配置钉钉消息', libs: ['validate','dataGrid']}){ %>
+<div class="main-content">
+	<div class="box box-main">
+		<div class="box-header with-border">
+			<div class="box-title">
+				<i class="fa icon-note"></i> ${text('配置钉钉消息')}
+			</div>
+			<div class="box-tools pull-right">
+				<button type="button" class="btn btn-box-tool" data-widget="collapse"><i class="fa fa-minus"></i></button>
+			</div>
+		</div>
+		<#form:form id="inputForm" model="${mesDingMsgConfig}" action="${ctx}/mes/mesDingMsgConfig/save" method="post" class="form-horizontal">
+			<div class="box-body">
+				<div class="form-unit">${text('基本信息(类型由系统预置,不可新增)')}</div>
+				<#form:hidden path="id"/>
+				<div class="row">
+					<div class="col-xs-6">
+						<div class="form-group">
+							<label class="control-label col-sm-4">${text('模块')}:</label>
+							<div class="col-sm-8">
+								<input type="text" class="form-control" value="${moduleName!}" readonly="readonly"/>
+							</div>
+						</div>
+					</div>
+					<div class="col-xs-6">
+						<div class="form-group">
+							<label class="control-label col-sm-4">${text('消息名称')}:</label>
+							<div class="col-sm-8">
+								<#form:input path="msgName" class="form-control" readonly="true"/>
+							</div>
+						</div>
+					</div>
+				</div>
+				<div class="row">
+					<div class="col-xs-12">
+						<div class="form-group">
+							<label class="control-label col-sm-2">${text('触发说明')}:</label>
+							<div class="col-sm-10">
+								<#form:textarea path="triggerDesc" class="form-control" rows="2" readonly="true"/>
+							</div>
+						</div>
+					</div>
+				</div>
+				<div class="row">
+					<div class="col-xs-6">
+						<div class="form-group">
+							<label class="control-label col-sm-4">${text('是否启用')}:</label>
+							<div class="col-sm-8">
+								<#form:radio path="isEnabled" dictType="sys_yes_no" class="form-control"/>
+							</div>
+						</div>
+					</div>
+				</div>
+				<div class="row">
+					<div class="col-xs-12">
+						<div class="form-group">
+							<label class="control-label col-sm-2">${text('发送内容')}:</label>
+							<div class="col-sm-10">
+								<input type="hidden" id="msgContent" name="msgContent" value=""/>
+								<div class="mb10">
+									<label class="text-muted">${text('卡片标题')}</label>
+									<input type="text" id="msgCardTitle" class="form-control" maxlength="80" placeholder="${text('例如:消息名称')}"/>
+								</div>
+								<table class="table table-bordered" id="msgCardItemTable" style="margin-bottom:8px;">
+									<thead>
+										<tr>
+											<th style="width:180px;">${text('项目')}</th>
+											<th>${text('内容')}</th>
+											<th style="width:70px;">${text('操作')}</th>
+										</tr>
+									</thead>
+									<tbody></tbody>
+								</table>
+								<div class="mt5">
+									<a href="javascript:" class="btn btn-xs btn-primary" id="btnAddCardItem"><i class="fa fa-plus"></i> ${text('添加一项')}</a>
+									<span class="text-muted ml10">${text('点击插入:')}</span>
+									<a href="javascript:" class="btn btn-xs btn-default btnInsertPh" data-token="{消息名称}">消息名称</a>
+									<a href="javascript:" class="btn btn-xs btn-default btnInsertPh" data-token="{发送时间}">发送时间</a>
+									<a href="javascript:" class="btn btn-xs btn-default btnInsertPh" data-token="{产线名称}">产线名称</a>
+									<a href="javascript:" class="btn btn-xs btn-default btnInsertPh" data-token="{产线编号}">产线编号</a>
+									<a href="javascript:" class="btn btn-xs btn-default btnInsertPh" data-token="{接收人}">接收人</a>
+									<a href="javascript:" class="btn btn-xs btn-default btnInsertPh" data-token="{触发说明}">触发说明</a>
+									<a href="javascript:" class="btn btn-xs btn-link" id="btnRestoreMsgContent">${text('恢复默认')}</a>
+								</div>
+								<div class="form-tips text-muted mt5">按钉钉工作通知卡片填写:左边是项目名,右边是内容。需要系统自动填的,先点一下要改的格子,再点上面按钮插入。</div>
+							</div>
+						</div>
+					</div>
+				</div>
+				<div class="row">
+					<div class="col-xs-12">
+						<div class="form-group">
+							<label class="control-label col-sm-2">${text('备注')}:</label>
+							<div class="col-sm-10">
+								<#form:textarea path="remarks" maxlength="500" class="form-control" rows="2"/>
+							</div>
+						</div>
+					</div>
+				</div>
+				<h4 class="form-unit">${text('按产线配置接收人')}</h4>
+				<div class="ml10 mr10 table-form">
+					<table id="mesDingMsgLineDataGrid"></table>
+					<% if (hasPermi('mes:mesDingMsgConfig:edit')){ %>
+						<a href="#" id="mesDingMsgLineDataGridAddRowBtn" class="btn btn-primary btn-sm mt10 mb10"><i class="fa fa-plus"></i> ${text('添加产线')}</a>
+					<% } %>
+				</div>
+			</div>
+			<div class="box-footer">
+				<div class="row">
+					<div class="col-sm-offset-2 col-sm-10">
+						<% if (hasPermi('mes:mesDingMsgConfig:edit')){ %>
+							<button type="submit" class="btn btn-sm btn-primary" id="btnSubmit"><i class="fa fa-check"></i> ${text('保 存')}</button>&nbsp;
+						<% } %>
+						<button type="button" class="btn btn-sm btn-default" id="btnCancel" onclick="js.closeCurrentTabPage()"><i class="fa fa-reply-all"></i> ${text('关 闭')}</button>
+					</div>
+				</div>
+			</div>
+		</#form:form>
+	</div>
+</div>
+<% } %>
+<script>
+var lineOptions = ${toJson(lineOptions)};
+$("#mesDingMsgLineDataGrid").dataGrid({
+	data: ${toJson(mesDingMsgConfig.mesDingMsgLineList)},
+	datatype: "local",
+	autoGridHeight: function(){return 'auto'},
+	columnModel: [
+		{header:'状态', name:'status', editable:true, hidden:true},
+		{header:'主键', name:'id', editable:true, hidden:true},
+		{header:'${text("配置ID")}', name:'configId', editable:true, hidden:true},
+		{header:'${text("产线")}', name:'lineSn', width:180, editable:true, edittype:'select', editoptions:{
+			'class':'form-control required',
+			items: $.merge([{id:'', title:'${text("请选择")}'}], lineOptions || []),
+			itemLabel: 'title', itemValue: 'id',
+			dataInit: function(element){
+				js.select2(element).on("change", function(){$(this).resetValid()});
+			}
+		}},
+		{header:'${text("接收人")}', name:'userCodes', width:280,
+			formatter: function(val, obj, row, act){
+				return js.val(row, 'userCodes')+'|'+js.val(row, 'userNames');
+			}, editable: true, edittype: "custom", editoptions: {
+				custom_element: function(val, editOptions) {
+					var text = val || '|';
+					var parts = (text + '').split('|');
+					return js.template('treeselectTpl', {
+						id: 'user_'+editOptions.id, title: '${text("选择接收人")}',
+						name: 'userCodes', value: parts[0] || '',
+						labelName: 'userNames', labelValue: parts[1] || '',
+						url: '${ctx}/sys/office/treeData?isLoadUser=true', cssClass: 'required'
+					});
+				}
+			}
+		},
+		{header:'${text("操作")}', name:'actions', width:80, align:'center', formatter: function(val, obj, row, act){
+			var actions = [];
+			if (val == 'new'){
+				actions.push('<a href="#" onclick="js.confirm(\'${text("你确认要删除这条数据吗?")}\', function(){$(\'#mesDingMsgLineDataGrid\').dataGrid(\'delRowData\',\''+obj.rowId+'\')});return false;"><i class="fa fa-trash-o"></i></a>&nbsp;');
+			}else{
+				actions.push('<a href="#" onclick="js.confirm(\'${text("你确认要删除这条数据吗?")}\', function(){$(\'#mesDingMsgLineDataGrid\').dataGrid(\'setRowData\',\''+obj.rowId+'\',null,{display:\'none\'});$(\'#'+obj.rowId+'_status\').val(\''+Global.STATUS_DELETE+'\');});return false;"><i class="fa fa-trash-o"></i></a>&nbsp;');
+			}
+			return actions.join('');
+		}, editoptions: {defaultValue: 'new'}}
+	],
+	editGrid: true,
+	editGridInitRowNum: 0,
+	editGridAddRowBtn: $('#mesDingMsgLineDataGridAddRowBtn'),
+	editGridAddRowBtnToHeader: true,
+	editGridAddRowInitData: {id: '', status: Global.STATUS_NORMAL, configId: '${mesDingMsgConfig.id!}'},
+	editGridInputFormListName: 'mesDingMsgLineList',
+	editGridInputFormListAttrs: 'status,id,configId,lineSn,userCodes,userNames,',
+	ajaxSuccess: function(data){
+	}
+});
+</script>
+<script>
+$("#inputForm").validate({
+	submitHandler: function(form){
+		$('#msgContent').val(serializeMsgCard());
+		js.ajaxSubmitForm($(form), function(data){
+			js.showMessage(data.message);
+			if(data.result == Global.TRUE){
+				js.closeCurrentTabPage(function(contentWindow){
+					contentWindow.page();
+				});
+			}
+		}, "json");
+	}
+});
+</script>
+<script>
+var defaultMsgContent = ${toJson(defaultContent!)};
+var msgCardData = ${toJson(msgCard!)};
+var lastCardFocus = null;
+function serializeMsgCard() {
+	var items = [];
+	$('#msgCardItemTable tbody tr').each(function(){
+		items.push({
+			label: $(this).find('.card-label').val() || '',
+			value: $(this).find('.card-value').val() || ''
+		});
+	});
+	return JSON.stringify({
+		title: $('#msgCardTitle').val() || '',
+		items: items
+	});
+}
+function addCardItem(label, value) {
+	var $tr = $('<tr></tr>');
+	$tr.append('<td><input type="text" class="form-control card-label" maxlength="40"/></td>');
+	$tr.append('<td><input type="text" class="form-control card-value" maxlength="200"/></td>');
+	$tr.append('<td class="text-center"><a href="javascript:" class="btnDelCardItem" title="${text("删除")}"><i class="fa fa-trash-o"></i></a></td>');
+	$tr.find('.card-label').val(label || '');
+	$tr.find('.card-value').val(value || '');
+	$('#msgCardItemTable tbody').append($tr);
+}
+function loadMsgCard(card) {
+	card = card || {};
+	$('#msgCardTitle').val(card.title || '');
+	$('#msgCardItemTable tbody').empty();
+	var items = card.items || [];
+	if (!items.length) {
+		addCardItem('', '');
+		return;
+	}
+	for (var i = 0; i < items.length; i++) {
+		addCardItem(items[i].label, items[i].value);
+	}
+}
+function insertMsgPlaceholder(token) {
+	var el = lastCardFocus;
+	if (!el) {
+		el = document.getElementById('msgCardTitle');
+	}
+	if (!el) {
+		return;
+	}
+	el.focus();
+	var val = el.value || '';
+	var start = typeof el.selectionStart === 'number' ? el.selectionStart : val.length;
+	var end = typeof el.selectionEnd === 'number' ? el.selectionEnd : val.length;
+	el.value = val.substring(0, start) + token + val.substring(end);
+	var pos = start + token.length;
+	if (el.setSelectionRange) {
+		el.setSelectionRange(pos, pos);
+	}
+}
+$('#msgCardTitle').on('focus', function(){
+	lastCardFocus = this;
+});
+$('#msgCardItemTable').on('focus', 'input', function(){
+	lastCardFocus = this;
+});
+$('#btnAddCardItem').on('click', function(){
+	addCardItem('', '');
+	return false;
+});
+$('#msgCardItemTable').on('click', '.btnDelCardItem', function(){
+	var $tbody = $('#msgCardItemTable tbody');
+	if ($tbody.find('tr').length <= 1) {
+		$(this).closest('tr').find('input').val('');
+		return false;
+	}
+	$(this).closest('tr').remove();
+	return false;
+});
+$('.btnInsertPh').on('click', function(){
+	insertMsgPlaceholder($(this).attr('data-token'));
+	return false;
+});
+$('#btnRestoreMsgContent').on('click', function(){
+	js.confirm('${text("确定恢复为默认发送内容吗?")}', function(){
+		var card = defaultMsgContent;
+		if (typeof card === 'string') {
+			try { card = JSON.parse(card); } catch (e) { card = {}; }
+		}
+		loadMsgCard(card);
+	});
+	return false;
+});
+loadMsgCard(msgCardData);
+</script>
+<script id="treeselectTpl" type="text/template">//<!--<div>
+<#form:treeselect id="{{d.id}}" title="{{d.title}}" name="{{d.name}}" value="{{d.value}}"
+	labelName="{{d.labelName}}" labelValue="{{d.labelValue}}" url="{{d.url}}"
+	class="{{d.cssClass}}" btnClass="btn-sm" allowClear="true" checkbox="true"/>
+</div>//--></script>

+ 167 - 0
src/main/resources/views/modules/mes/mesDingMsgConfigList.html

@@ -0,0 +1,167 @@
+<% 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-speech"></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>
+				<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="${mesDingMsgConfig}" action="${ctx}/mes/mesDingMsgConfig/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="msgName" maxlength="100" class="form-control width-120"/>
+					</div>
+				</div>
+				<div class="form-group">
+					<label class="control-label">${text('模块')}:</label>
+					<div class="control-inline">
+						<select name="module" class="form-control">
+							<option value="">${text('全部')}</option>
+							<option value="DEVICE">${text('设备')}</option>
+							<option value="PROD">${text('生产')}</option>
+							<option value="QUALITY">${text('质量')}</option>
+						</select>
+					</div>
+				</div>
+				<div class="form-group">
+					<label class="control-label">${text('是否启用')}:</label>
+					<div class="control-inline">
+						<#form:select path="isEnabled" dictType="sys_yes_no" 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>
+<% } %>
+<script>
+var moduleMap = {DEVICE:'设备', PROD:'生产', QUALITY:'质量'};
+$('#dataGrid').dataGrid({
+	searchForm: $("#searchForm"),
+	columnModel: [
+		{header:'${text("模块")}', name:'module', index:'a.module', width:80, align:"center", formatter: function(val){
+			return moduleMap[val] || val || '';
+		}},
+		{header:'${text("消息名称")}', name:'msgName', index:'a.msg_name', width:180, align:"left", formatter: function(val, obj, row){
+			return '<a href="${ctx}/mes/mesDingMsgConfig/form?id='+row.id+'" class="btnList" data-title="${text("配置钉钉消息")}">'+(val||row.msgCode)+'</a>';
+		}},
+		{header:'${text("触发说明")}', name:'triggerDesc', index:'a.trigger_desc', width:200, align:"left"},
+		{header:'${text("发送内容")}', name:'msgContent', index:'a.msg_content', width:240, align:"left", formatter: function(val){
+			if (!val) { return '<span class="text-muted">未配置</span>'; }
+			var text = '';
+			try {
+				var card = JSON.parse(val);
+				if (card && (card.title || (card.items && card.items.length))) {
+					text = card.title || '';
+					if (card.items && card.items.length) {
+						text = (text ? text + ';' : '') + card.items.map(function(it){
+							return (it.label || '') + (it.value ? ':' + it.value : '');
+						}).join(',');
+					}
+				}
+			} catch (e) {}
+			if (!text) { text = String(val).replace(/\s+/g, ' '); }
+			if (text.length > 40) { text = text.substring(0, 40) + '...'; }
+			return js.encodeHtml ? js.encodeHtml(text) : text.replace(/</g,'&lt;');
+		}},
+		{header:'${text("已配产线")}', name:'lineSummary', width:180, align:"left", sortable:false},
+		{header:'${text("是否启用")}', name:'isEnabled', index:'a.is_enabled', width:80, align:"center", formatter: function(val){
+			return val == '1' ? '<span class="text-success">启用</span>' : '<span class="text-muted">停用</span>';
+		}},
+		{header:'${text("操作")}', name:'actions', width:160, sortable:false, formatter: function(val, obj, row){
+			var actions = [];
+			<% if(hasPermi('mes:mesDingMsgConfig:edit')){ %>
+				actions.push('<a href="${ctx}/mes/mesDingMsgConfig/form?id='+row.id+'" class="btnList" title="${text("配置")}"><i class="fa fa-pencil"></i></a>&nbsp;');
+				if (row.isEnabled == '1') {
+					actions.push('<a href="javascript:" class="btnEnable" data-id="'+row.id+'" data-enabled="0" title="${text("停用")}"><i class="fa fa-pause"></i></a>&nbsp;');
+				} else {
+					actions.push('<a href="javascript:" class="btnEnable" data-id="'+row.id+'" data-enabled="1" title="${text("启用")}"><i class="fa fa-play"></i></a>&nbsp;');
+				}
+				actions.push('<a href="javascript:" class="btnTestSend" data-id="'+row.id+'" data-lines="'+encodeURIComponent(JSON.stringify(row.mesDingMsgLineList || []))+'" title="${text("测试发送")}"><i class="fa fa-paper-plane"></i></a>&nbsp;');
+			<% } %>
+			return actions.join('');
+		}}
+	],
+	ajaxSuccess: function(data){
+	}
+});
+
+$('#dataGrid').on('click', '.btnEnable', function(){
+	var id = $(this).attr('data-id');
+	var enabled = $(this).attr('data-enabled');
+	var tip = enabled == '1' ? '${text("确认启用该消息推送吗?")}' : '${text("确认停用该消息推送吗?")}';
+	js.confirm(tip, function(){
+		js.ajaxSubmit('${ctx}/mes/mesDingMsgConfig/enable', {id: id, isEnabled: enabled}, function(data){
+			js.showMessage(data.message);
+			if (data.result == Global.TRUE) {
+				page();
+			}
+		}, 'json');
+	});
+});
+
+$('#dataGrid').on('click', '.btnTestSend', function(){
+	var id = $(this).attr('data-id');
+	var lines = [];
+	try {
+		lines = JSON.parse(decodeURIComponent($(this).attr('data-lines') || '%5B%5D'));
+	} catch (e) {
+		lines = [];
+	}
+	openTestSend(id, lines);
+});
+
+function openTestSend(id, lines) {
+	if (!lines.length) {
+		js.showMessage('${text("请先配置产线接收人")}');
+		return;
+	}
+	if (lines.length == 1) {
+		doTestSend(id, lines[0].lineSn);
+		return;
+	}
+	var options = [];
+	for (var i = 0; i < lines.length; i++) {
+		var line = lines[i];
+		var title = (line.lineTitle || line.lineSn) + (line.userNames ? ' - ' + line.userNames : '');
+		options.push('<option value="' + escapeHtml(line.lineSn) + '">' + escapeHtml(title) + '</option>');
+	}
+	js.layer.open({
+		type: 1,
+		area: ['420px', '220px'],
+		title: '${text("选择测试产线")}',
+		resize: false,
+		content: '<div class="p20"><select id="testLineSn" class="form-control">' + options.join('') + '</select></div>',
+		btn: ['<i class="fa fa-paper-plane"></i> ${text("发送")}', '<i class="fa fa-remove"></i> ${text("取消")}'],
+		btn1: function(index, layero){
+			var lineSn = layero.find('#testLineSn').val();
+			js.layer.close(index);
+			doTestSend(id, lineSn);
+			return true;
+		}
+	});
+}
+
+function doTestSend(id, lineSn) {
+	js.ajaxSubmit('${ctx}/mes/mesDingMsgConfig/testSend', {id: id, lineSn: lineSn}, function(data){
+		js.showMessage(data.message);
+	}, 'json');
+}
+
+function escapeHtml(s) {
+	return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
+}
+</script>