20260426:条件触发任务

This commit is contained in:
George 2026-04-26 12:27:01 +08:00
parent d433c25f7b
commit 49cd12e1e3
6 changed files with 632 additions and 42 deletions

View File

@ -0,0 +1,218 @@
package com.fastbee.iot.data.executor;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.fastbee.common.constant.FastBeeConstant;
import com.fastbee.common.core.mq.message.DeviceMessage;
import com.fastbee.common.core.thingsModel.ThingsModelSimpleItem;
import com.fastbee.common.utils.StringUtils;
import com.fastbee.iot.data.service.IDeviceMessageService;
import com.fastbee.iot.domain.ModbusJob;
import com.fastbee.iot.service.IModbusJobService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
/**
* 条件触发任务执行器(cycleType=4)
* <p>
* 每次设备上报属性/功能数据时,检查是否存在满足条件的 Modbus 任务,
* 满足则立即下发对应的 Modbus 指令(与上电触发使用同一套指令下发通道)。
* <p>
* 条件存储在 {@code iot_modbus_job.remark} 字段中,JSON 格式:
* <pre>{"identifier":"temp","operator":">","value":"50"}</pre>
* 支持的 operator:{@code = != > < >= <=}
*
* @author Sumuxi
*/
@Slf4j
@Component
public class ConditionTriggerTaskExecutor {
@Resource
private IModbusJobService modbusJobService;
@Resource
private IDeviceMessageService deviceMessageService;
/**
* 异步检查并执行条件触发任务。
* 由 {@code DataHandlerImpl.reportData()} 在每次属性/功能上报后调用。
*
* @param serialNumber 上报数据的设备编号(子设备或直连设备)
* @param thingsModelSimpleItems 本次上报的物模型值列表
*/
@Async(FastBeeConstant.TASK.TRIGGER_POWER_ON_TASK_ASYNC)
public void checkAndTrigger(String serialNumber, List<ThingsModelSimpleItem> thingsModelSimpleItems) {
if (StringUtils.isEmpty(serialNumber) || CollectionUtils.isEmpty(thingsModelSimpleItems)) {
return;
}
// 查询该设备所有状态正常的条件触发任务
ModbusJob query = new ModbusJob();
query.setSubSerialNumber(serialNumber);
query.setStatus("0");
query.setCycleType(4);
List<ModbusJob> jobList = modbusJobService.selectModbusJobList(query);
if (CollectionUtils.isEmpty(jobList)) {
return;
}
for (ModbusJob job : jobList) {
try {
if (isConditionMet(job, thingsModelSimpleItems)) {
dispatchCommand(serialNumber, job);
}
} catch (Exception e) {
log.error("[条件触发] 执行异常,设备:{},任务ID:{}", serialNumber, job.getTaskId(), e);
}
}
}
/**
* 判断本次上报数据是否满足任务配置的条件。
* <p>
* 支持两种 remark 格式:
* <ul>
* <li>多条件:{@code {"logic":"and","conditions":[{...},{...}]}}</li>
* </ul>
* logic 为 "or" 时任一条件满足即触发;默认("and")要求全部满足。
*/
private boolean isConditionMet(ModbusJob job, List<ThingsModelSimpleItem> thingsModelSimpleItems) {
String remark = job.getRemark();
if (StringUtils.isEmpty(remark)) {
log.warn("[条件触发] 任务 {} remark 为空,跳过", job.getTaskId());
return false;
}
JSONObject json;
try {
json = JSON.parseObject(remark);
} catch (Exception e) {
log.warn("[条件触发] 任务 {} remark 解析失败:{}", job.getTaskId(), remark);
return false;
}
JSONArray conditions = json.getJSONArray("conditions");
if (conditions == null || conditions.isEmpty()) {
log.warn("[条件触发] 任务 {} conditions 为空", job.getTaskId());
return false;
}
String logic = json.getString("logic");
boolean isOr = "or".equalsIgnoreCase(logic);
for (int i = 0; i < conditions.size(); i++) {
JSONObject cond = conditions.getJSONObject(i);
boolean met = evaluateSingleCondition(cond, thingsModelSimpleItems, job.getTaskId());
if (isOr && met) return true;
if (!isOr && !met) return false;
}
return !isOr; // AND:全部通过; OR:无一通过
}
/**
* 评估单个条件对象(含 identifier / operator / value 字段)。
*/
private boolean evaluateSingleCondition(JSONObject condition,
List<ThingsModelSimpleItem> items,
Long taskId) {
String identifier = condition.getString("identifier");
String operator = condition.getString("operator");
String condValue = condition.getString("value");
if (StringUtils.isEmpty(identifier) || StringUtils.isEmpty(operator) || condValue == null) {
log.warn("[条件触发] 任务 {} 条件配置不完整:{}", taskId, condition);
return false;
}
String reportedValue = null;
for (ThingsModelSimpleItem item : items) {
if (identifier.equals(item.getId())) {
reportedValue = item.getValue();
break;
}
}
if (reportedValue == null) {
// 本次上报不含该属性,条件视为不满足
return false;
}
return evaluate(reportedValue, operator, condValue);
}
/**
* 条件表达式计算。
* 优先尝试数值比较,失败则降级为字符串比较。
*/
private boolean evaluate(String actual, String operator, String expected) {
try {
BigDecimal a = new BigDecimal(actual.trim());
BigDecimal b = new BigDecimal(expected.trim());
int cmp = a.compareTo(b);
switch (operator) {
case "=": return cmp == 0;
case "!=": return cmp != 0;
case ">": return cmp > 0;
case "<": return cmp < 0;
case ">=": return cmp >= 0;
case "<=": return cmp <= 0;
default:
log.warn("[条件触发] 不支持的操作符:{}", operator);
return false;
}
} catch (NumberFormatException e) {
// 非数值,降级为字符串等于/不等于
switch (operator) {
case "=": return actual.equals(expected);
case "!=": return !actual.equals(expected);
default:
log.warn("[条件触发] 非数值不支持操作符 {},actual={}", operator, actual);
return false;
}
}
}
/**
* 下发 Modbus 指令(HEX 格式)。
* 复用与上电触发相同的消息通道。
*/
private void dispatchCommand(String serialNumber, ModbusJob job) {
String command = job.getCommand();
if (StringUtils.isEmpty(command)) {
log.warn("[条件触发] 任务 {} 指令为空,跳过下发", job.getTaskId());
return;
}
DeviceMessage msg = new DeviceMessage();
msg.setSerialNumber(serialNumber);
msg.setTopicName("/function/get");
msg.setDataType("hex");
msg.setMessage(command);
try {
deviceMessageService.messagePost(msg);
// 记录执行时间
updateExecuteTime(job.getTaskId());
log.info("[条件触发] 下发成功,设备:{},任务ID:{},指令:{}", serialNumber, job.getTaskId(), command);
} catch (Exception e) {
log.error("[条件触发] 下发异常,设备:{},任务ID:{}", serialNumber, job.getTaskId(), e);
}
}
/**
* 更新任务最近执行时间。
*/
private void updateExecuteTime(Long taskId) {
if (taskId == null) return;
ModbusJob update = new ModbusJob();
update.setTaskId(taskId);
update.setExecuteTime(new Date());
modbusJobService.updateModbusJob(update);
}
}

View File

@ -89,6 +89,7 @@ public class SceneContext {
/**
* 处理规则脚本
* 静默时间:silent,在静默时间内即使满足触发条件也不执行动作
*
* @return
*/
private boolean process(String json) throws InterruptedException {
@ -96,7 +97,8 @@ public class SceneContext {
ScriptTemplate scriptTemplate = JSON.parseObject(json, ScriptTemplate.class);
String requestId = "scene/" + scriptTemplate.getChainName();
scene_logger.info("[{}]=====+>场景联动规则开始执行!", requestId);
scene_logger.info("[{}]=====+>规则引擎脚本配置:{},规则引擎上下文:{}", requestId, JSON.toJSONString(scriptTemplate), JSON.toJSONString(this));
scene_logger.info("[{}]=====+>规则引擎脚本配置:{},规则引擎上下文:{}", requestId, JSON.toJSONString(scriptTemplate),
JSON.toJSONString(this));
// MDC.put("id", scriptTemplate.getChainName());
// 触发器
if (scriptTemplate.getPurpose() == 2) {
@ -513,7 +515,8 @@ public class SceneContext {
alertPushParams.setDeviceId(device.getDeviceId());
alertPushParams.setDeviceName(device.getDeviceName());
alertPushParams.setSerialNumber(sceneThingsModelItem.getDeviceNumber());
// List<DeviceUser> deviceUserList = deviceUserService.getDeviceUserAndShare(device.getDeviceId());
// List<DeviceUser> deviceUserList =
// deviceUserService.getDeviceUserAndShare(device.getDeviceId());
// 多租户改版查询自己配置的告警用户 默认优先推送本账号超级管理员
Set<Long> userIdSet = new HashSet<>();
try {
@ -547,7 +550,8 @@ public class SceneContext {
return alertPushParams;
}
private AlertLog getTerminalUserAlertLog(SceneTerminalUserVO sceneTerminalUserVO, Device device, SceneThingsModelItem sceneThingsModelItem) {
private AlertLog getTerminalUserAlertLog(SceneTerminalUserVO sceneTerminalUserVO, Device device,
SceneThingsModelItem sceneThingsModelItem) {
AlertLog alertLog = new AlertLog();
alertLog.setAlertName("设备告警");
alertLog.setAlertLevel(1L);

View File

@ -17,6 +17,7 @@ import com.fastbee.common.utils.StringUtils;
import com.fastbee.common.utils.date.LocalDateTimeUtils;
import com.fastbee.common.utils.gateway.mq.TopicsUtils;
import com.fastbee.iot.cache.SceneModelTagCache;
import com.fastbee.iot.data.executor.ConditionTriggerTaskExecutor;
import com.fastbee.iot.data.service.IDataHandler;
import com.fastbee.iot.data.service.IMqttMessagePublish;
import com.fastbee.iot.data.service.IRuleEngine;
@ -59,6 +60,8 @@ public class DataHandlerImpl implements IDataHandler {
@Resource
private IRuleEngine ruleEngine;
@Resource
private ConditionTriggerTaskExecutor conditionTriggerExecutor;
@Resource
private MqttRemoteManager remoteManager;
@Resource
private TopicsUtils topicsUtils;
@ -100,6 +103,8 @@ public class DataHandlerImpl implements IDataHandler {
// todo 只有设备上报进入规则引擎流程
if (bo.isRuleEngine() && !bo.getSerialNumber().startsWith("server-")) {
ruleEngine.ruleMatch(bo);
// 条件触发任务检查:根据本次上报值决定是否下发 Modbus 指令
conditionTriggerExecutor.checkAndTrigger(bo.getSerialNumber(), result);
}
// 发送至前端
PushMessageBo messageBo = new PushMessageBo();

View File

@ -84,13 +84,13 @@ public class ModbusJob extends BaseEntity {
private String remarkStr;
/**
* 时间周期类型(1-周期循环,2-自定义时间段,3-设备上电触发)
* 时间周期类型(1-周期循环,2-自定义时间段,3-设备上电触发,4-条件触发)
*/
@ApiModelProperty("时间周期类型")
private Integer cycleType;
/**
* 执行时间(仅设备上电触发任务使用,记录任务执行的时间)
* 执行时间(仅设备上电触发/条件触发任务使用,记录最近一次执行的时间)
*/
@ApiModelProperty("执行时间")
private Date executeTime;

View File

@ -1,5 +1,6 @@
package com.fastbee.iot.service.impl;
import com.alibaba.fastjson2.JSONObject;
import com.fastbee.common.core.device.DeviceAndProtocol;
import com.fastbee.common.core.mq.SubDeviceBo;
import com.fastbee.common.exception.ServiceException;
@ -69,9 +70,13 @@ public class ModbusJobServiceImpl implements IModbusJobService {
List<ModbusJob> modbusJobList = modbusJobMapper.selectModbusJobList(modbusJob);
if (!CollectionUtils.isEmpty(modbusJobList)) {
for (ModbusJob job : modbusJobList) {
// 直接使用数据库中的 cycleType 判断
if (job.getCycleType() != null && job.getCycleType() == 3) {
Integer ct = job.getCycleType();
if (ct != null && ct == 3) {
job.setRemarkStr("设备上电触发");
} else if (ct != null && ct == 4) {
// 条件触发:remark 是 JSON 对象,解析后展示为可读描述
String condDesc = buildConditionDesc(job.getRemark());
job.setRemarkStr("条件触发:" + condDesc);
} else {
job.setRemarkStr(JobCronUtils.handleCronCycle(1, job.getRemark()).getDesc());
job.setCycleType(1); // 周期循环(默认)
@ -83,13 +88,14 @@ public class ModbusJobServiceImpl implements IModbusJobService {
}
/**
* 检查是否是设备上电触发(直接使用 cycleType 判断)
* 检查是否是非定时触发类型(上电触发 cycleType=3 或条件触发 cycleType=4)
* 这两种类型不需要创建 Quartz 定时任务,而是通过事件驱动触发。
*
* @param cycleType 时间周期类型
* @return true表示是设备上电触发
* @return true 表示不需要创建定时任务
*/
private boolean isPowerOnTrigger(Integer cycleType) {
return cycleType != null && cycleType == 3;
private boolean isNonScheduleTrigger(Integer cycleType) {
return cycleType != null && (cycleType == 3 || cycleType == 4);
}
/**
@ -107,7 +113,8 @@ public class ModbusJobServiceImpl implements IModbusJobService {
}
// 查询关联的网关设备
DeviceAndProtocol deviceAndProtocol = deviceService.selectProtocolBySerialNumber(modbusJob.getSubSerialNumber());
DeviceAndProtocol deviceAndProtocol = deviceService
.selectProtocolBySerialNumber(modbusJob.getSubSerialNumber());
// 如果是网关子设备
if (deviceAndProtocol.getDeviceType() == DeviceType.SUB_GATEWAY.getCode() ||
deviceAndProtocol.getDeviceType() == DeviceType.GATEWAY.getCode()) {
@ -129,14 +136,12 @@ public class ModbusJobServiceImpl implements IModbusJobService {
deviceJob.setSerialNumber(deviceAndProtocol.getGwSerialNumber());
deviceJob.setProductId(deviceAndProtocol.getGwProductId());
deviceJob.setCreateTime(DateUtils.getNowDate());
// 处理时间转换
// 检查是否是设备上电触发(使用 cycleType 判断)
if (isPowerOnTrigger(modbusJob.getCycleType())) {
// 设备上电触发不需要 cron 表达式,设置为null
// DeviceJobServiceImpl.insertJob 会检查并跳过创建定时任务
// 上电触发(3)和条件触发(4)不需要 cron 表达式
if (isNonScheduleTrigger(modbusJob.getCycleType())) {
deviceJob.setCronExpression(null);
} else {
SceneModelTagCycleVO sceneModelTagCycleVO = JobCronUtils.handleCronCycle(1, deviceJob.getRemark());
SceneModelTagCycleVO sceneModelTagCycleVO = JobCronUtils.handleCronCycle(1,
deviceJob.getRemark());
deviceJob.setCronExpression(sceneModelTagCycleVO.getCron());
}
deviceJobService.insertJob(deviceJob);
@ -150,7 +155,7 @@ public class ModbusJobServiceImpl implements IModbusJobService {
DeviceJob deviceJob = new DeviceJob();
deviceJob.setDeviceId(deviceAndProtocol.getDeviceId()); // 用网关自己的deviceId
buildSaveDeviceJob(modbusJob, deviceAndProtocol, deviceJob);
} else {
} else {
throw new ServiceException("请先绑定网关");
}
} else {
@ -200,7 +205,8 @@ public class ModbusJobServiceImpl implements IModbusJobService {
if (scheduleChanged) {
// 计算新的 remark / cycleType(允许只传 cycleType 或只传 remark)
String newRemark = modbusJob.getRemark() != null ? modbusJob.getRemark() : existingJob.getRemark();
Integer newCycleType = modbusJob.getCycleType() != null ? modbusJob.getCycleType() : existingJob.getCycleType();
Integer newCycleType = modbusJob.getCycleType() != null ? modbusJob.getCycleType()
: existingJob.getCycleType();
if (newCycleType == null) {
newCycleType = 1;
}
@ -228,15 +234,20 @@ public class ModbusJobServiceImpl implements IModbusJobService {
create.setSerialNumber(oldDeviceJob.getSerialNumber());
create.setProductId(oldDeviceJob.getProductId());
create.setJobType(5);
create.setJobName(StringUtils.isNotEmpty(oldDeviceJob.getJobName()) ? oldDeviceJob.getJobName() : "modbus poll");
create.setJobGroup(StringUtils.isNotEmpty(oldDeviceJob.getJobGroup()) ? oldDeviceJob.getJobGroup() : "MODBUS");
create.setMisfirePolicy(StringUtils.isNotEmpty(oldDeviceJob.getMisfirePolicy()) ? oldDeviceJob.getMisfirePolicy() : "2");
create.setConcurrent(StringUtils.isNotEmpty(oldDeviceJob.getConcurrent()) ? oldDeviceJob.getConcurrent() : "1");
create.setJobName(
StringUtils.isNotEmpty(oldDeviceJob.getJobName()) ? oldDeviceJob.getJobName() : "modbus poll");
create.setJobGroup(
StringUtils.isNotEmpty(oldDeviceJob.getJobGroup()) ? oldDeviceJob.getJobGroup() : "MODBUS");
create.setMisfirePolicy(
StringUtils.isNotEmpty(oldDeviceJob.getMisfirePolicy()) ? oldDeviceJob.getMisfirePolicy()
: "2");
create.setConcurrent(
StringUtils.isNotEmpty(oldDeviceJob.getConcurrent()) ? oldDeviceJob.getConcurrent() : "1");
create.setIsAdvance(oldDeviceJob.getIsAdvance() != null ? oldDeviceJob.getIsAdvance() : 0);
create.setStatus("0");
create.setRemark(newRemark);
if (isPowerOnTrigger(newCycleType)) {
if (isNonScheduleTrigger(newCycleType)) {
create.setCronExpression(null);
} else {
SceneModelTagCycleVO vo = JobCronUtils.handleCronCycle(newCycleType, newRemark);
@ -258,13 +269,14 @@ public class ModbusJobServiceImpl implements IModbusJobService {
int rows = modbusJobMapper.updateModbusJob(modbusJob);
// 如发生重绑且旧 jobId 无引用,则删除旧 DeviceJob(同时会清理 quartz trigger/job)
if (scheduleChanged && oldDeviceJobId != null && modbusJob.getJobId() != null && !oldDeviceJobId.equals(modbusJob.getJobId())) {
if (scheduleChanged && oldDeviceJobId != null && modbusJob.getJobId() != null
&& !oldDeviceJobId.equals(modbusJob.getJobId())) {
ModbusJob q = new ModbusJob();
q.setJobId(oldDeviceJobId);
List<ModbusJob> left = modbusJobMapper.selectModbusJobList(q);
if (CollectionUtils.isEmpty(left)) {
try {
deviceJobService.deleteJobByIds(new Long[]{oldDeviceJobId});
deviceJobService.deleteJobByIds(new Long[] { oldDeviceJobId });
} catch (SchedulerException e) {
throw new ServiceException(e.getMessage());
}
@ -332,7 +344,8 @@ public class ModbusJobServiceImpl implements IModbusJobService {
case GATEWAY:
// 网关设备需要查询所有子设备的modbus任务
List<SubDeviceBo> subDeviceList = subGatewayService.getSubDeviceListByGw(serialNumber);
List<String> serialNumberList = subDeviceList.stream().map(SubDeviceBo::getSubDeviceNo).collect(Collectors.toList());
List<String> serialNumberList = subDeviceList.stream().map(SubDeviceBo::getSubDeviceNo)
.collect(Collectors.toList());
// 把网关自身SN也加入查询
serialNumberList.add(serialNumber);
modbusJob.setSubDeviceList(serialNumberList);
@ -360,9 +373,10 @@ public class ModbusJobServiceImpl implements IModbusJobService {
/**
* 组装并保存设备任务
* @param modbusJob 待新增的modbus任务
* @param deviceAndProtocol 设备协议
* @param deviceJob 设备任务
*
* @param modbusJob 待新增的modbus任务
* @param deviceAndProtocol 设备协议
* @param deviceJob 设备任务
*/
private void buildSaveDeviceJob(ModbusJob modbusJob, DeviceAndProtocol deviceAndProtocol, DeviceJob deviceJob) {
deviceJob.setRemark(modbusJob.getRemark());
@ -379,13 +393,10 @@ public class ModbusJobServiceImpl implements IModbusJobService {
deviceJob.setSerialNumber(deviceAndProtocol.getSerialNumber()); // 网关自己的SN
deviceJob.setProductId(deviceAndProtocol.getProductId());
deviceJob.setCreateTime(DateUtils.getNowDate());
// 检查是否是设备上电触发(使用 cycleType 判断)
if (isPowerOnTrigger(modbusJob.getCycleType())) {
// 设备上电触发不需要 cron 表达式,设置为null
// DeviceJobServiceImpl.insertJob 会检查并跳过创建定时任务
// 上电触发(3)和条件触发(4)不需要 cron 表达式
if (isNonScheduleTrigger(modbusJob.getCycleType())) {
deviceJob.setCronExpression(null);
} else {
// 处理时间转换
SceneModelTagCycleVO sceneModelTagCycleVO = JobCronUtils.handleCronCycle(1, deviceJob.getRemark());
deviceJob.setCronExpression(sceneModelTagCycleVO.getCron());
}
@ -400,4 +411,48 @@ public class ModbusJobServiceImpl implements IModbusJobService {
modbusJob.setJobId(job.getJobId());
}
}
/**
* 将条件触发的 remark JSON 转换为可读描述,用于列表展示。
* 示例:{"identifier":"temperature","operator":">","value":"50"} → "temperature >
* 50"
*/
private String buildConditionDesc(String remark) {
if (StringUtils.isEmpty(remark)) {
return "(条件未配置)";
}
try {
JSONObject json = JSONObject.parseObject(remark);
// 多条件格式
if (json.containsKey("conditions")) {
com.alibaba.fastjson2.JSONArray conditions = json.getJSONArray("conditions");
if (conditions == null || conditions.isEmpty()) {
return "(条件未配置)";
}
String logic = json.getString("logic");
String sep = "or".equalsIgnoreCase(logic) ? " 或 " : " 且 ";
StringBuilder sb = new StringBuilder();
for (int i = 0; i < conditions.size(); i++) {
JSONObject cond = conditions.getJSONObject(i);
String id = cond.getString("identifier");
String op = cond.getString("operator");
String val = cond.getString("value");
if (i > 0)
sb.append(sep);
sb.append(id).append(op).append(val);
}
return sb.toString();
}
// 单条件旧格式
String id = json.getString("identifier");
String op = json.getString("operator");
String val = json.getString("value");
if (StringUtils.isEmpty(id) || StringUtils.isEmpty(op) || val == null) {
return remark;
}
return id + " " + op + " " + val;
} catch (Exception e) {
return remark;
}
}
}

View File

@ -66,6 +66,7 @@
<el-tag v-if="scope.row.cycleType === 1" type="primary" size="small">周期循环</el-tag>
<el-tag v-else-if="scope.row.cycleType === 2" type="warning" size="small">自定义时间段</el-tag>
<el-tag v-else-if="scope.row.cycleType === 3" type="success" size="small">设备上电触发</el-tag>
<el-tag v-else-if="scope.row.cycleType === 4" type="danger" size="small">条件触发</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
@ -83,7 +84,11 @@
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column :label="$t('device.device-modbus-task.384302-59')" align="center" prop="remarkStr"></el-table-column>
<el-table-column :label="$t('device.device-modbus-task.384302-59')" align="center" min-width="200" class-name="modbus-task-schedule-col">
<template slot-scope="scope">
<span class="modbus-task-schedule-text">{{ formatModbusScheduleDisplay(scope.row) }}</span>
</template>
</el-table-column>
<el-table-column :label="$t('device.device-modbus-task.384302-60')" align="center" class-name="small-padding fixed-width">
<template slot-scope="scope">
<el-button size="mini" type="text" icon="el-icon-edit" @click="handleEdit(scope.row)" v-hasPermi="['modbus:job:edit']">编辑</el-button>
@ -331,6 +336,84 @@
<i class="el-icon-question" style="color: #909399; margin-left: 5px;"></i>
</el-tooltip>
</el-radio>
<!-- 条件触发 -->
<el-radio :label="4" style="display: block; margin-top: 10px;">
<span>条件触发</span>
<el-tooltip placement="right">
<div slot="content">
当设备上报的指定属性满足条件时,自动下发本条指令
</div>
<i class="el-icon-question" style="color: #909399; margin-left: 5px;"></i>
</el-tooltip>
<!-- 条件配置区域(多条件) -->
<div v-show="createForm.cycleType === 4" style="margin-top: 10px; padding: 10px; background: #f5f7fa; border-radius: 4px;">
<!-- 多条件逻辑选择(≥2条时显示) -->
<div v-if="createForm.conditions.length > 1" style="margin-bottom: 8px; display: flex; align-items: center; gap: 6px;">
<span style="font-size:12px; color:#606266;">条件关系:</span>
<el-radio-group v-model="createForm.conditionLogic" size="mini">
<el-radio-button label="and">全部满足(且)</el-radio-button>
<el-radio-button label="or">任一满足(或)</el-radio-button>
</el-radio-group>
</div>
<!-- 条件行列表 -->
<div
v-for="(cond, idx) in createForm.conditions"
:key="idx"
style="display: flex; align-items: flex-end; gap: 6px; margin-bottom: 6px;"
>
<!-- 多条件连接词标签 -->
<span v-if="idx > 0" style="font-size:12px; color:#409EFF; white-space: nowrap; padding-bottom: 6px; min-width: 30px;">
{{ createForm.conditionLogic === 'or' ? '或' : '且' }}
</span>
<span v-else style="min-width: 30px;"></span>
<el-select
v-model="cond.identifier"
size="small"
placeholder="监听属性"
filterable
style="flex: 2;"
>
<el-option
v-for="prop in conditionProperties"
:key="prop.id"
:label="prop.name + '(' + prop.id + ')' + (prop.isCalculated ? ' [计算]' : '')"
:value="prop.id"
/>
</el-select>
<el-select v-model="cond.operator" size="small" style="flex: 1.2;">
<el-option label="等于(=)" value="=" />
<el-option label="不等于(!=)" value="!=" />
<el-option label="大于(>)" value=">" />
<el-option label="小于(<)" value="<" />
<el-option label="大于等于(>=)" value=">=" />
<el-option label="小于等于(<=)" value="<=" />
</el-select>
<el-input
v-model="cond.value"
size="small"
placeholder="阈值"
style="flex: 1;"
/>
<el-button
v-if="createForm.conditions.length > 1"
type="danger"
icon="el-icon-minus"
size="mini"
circle
@click.stop="removeCondition(idx)"
/>
</div>
<!-- 添加条件按钮 -->
<el-button
type="primary"
icon="el-icon-plus"
size="mini"
plain
@click.stop="addCondition"
style="margin-top: 2px;"
>添加条件</el-button>
</div>
</el-radio>
</el-radio-group>
</div>
</el-form-item>
@ -363,6 +446,26 @@ import { parseTime } from '@/utils/ruoyi';
import { encode } from '@/api/iot/mqttTest';
import { listJob, getJob, delJob, addJob, updateJob, getSlaveId } from '@/api/iot/modbusJob';
import { listGateway } from '@/api/iot/gateway';
import { listByProductIdAndSerialNumber, cacheJsonThingsModel } from '@/api/iot/model';
/**
* 计算型物模型且公式含跨从机引用 {slaveId:N.xxx}(与后端 CalculateUtils 一致)。
* 条件触发依赖与上报同一链路的物模型值,跨设备计算量为查询/聚合侧数据,不纳入可选监听属性。
*/
function isCrossDeviceCalculatedModel(isCalculated, formula) {
const ic = isCalculated === 1 || isCalculated === '1';
if (!ic) {
return false;
}
if (formula == null) {
return false;
}
const s = String(formula);
if (s.trim() === '') {
return false;
}
return /\{slaveId:\d+\.[^}]+}/.test(s);
}
export default {
name: 'device-modbus-task',
@ -515,6 +618,8 @@ export default {
},
cycles1: [{ interval: '300', time: '', week: '', day: '' }],
cycles2: [{ type: 'day', time: '00', week: '', day: '', toType: '1', toTime: '02', toWeek: '', toDay: '' }],
// 条件触发:当前产品的属性列表({id, name} 结构)
conditionProperties: [],
};
},
methods: {
@ -522,7 +627,7 @@ export default {
showExecuteTime(row) {
const t = row.cycleType;
if (t === 1 || t === 2) return true;
if (t === 3) return row.status === '1';
if (t === 3 || t === 4) return !!row.executeTime;
return false;
},
// 回填时间周期(remark -> cycles1/cycles2)
@ -672,7 +777,7 @@ export default {
/**计算定时时间*/
let cycle = '';
// 如果是设备上电触发,不需要 cron 表达式
// 设备上电触发(3)和条件触发(4)不需要 cron 表达式
if (this.createForm.cycleType === 3) {
this.createForm.subDeviceId = this.device.deviceId;
this.createForm.subSerialNumber = this.device.serialNumber;
@ -683,6 +788,31 @@ export default {
this.editDialog = false;
return;
}
if (this.createForm.cycleType === 4) {
const validConds = (this.createForm.conditions || []).filter(
(c) => c.identifier && c.operator && c.value !== undefined && c.value !== ''
);
if (validConds.length === 0) {
this.$message({ type: 'warning', message: '条件触发:至少填写一条完整的条件(属性 + 操作符 + 阈值)' });
return;
}
this.createForm.subDeviceId = this.device.deviceId;
this.createForm.subSerialNumber = this.device.serialNumber;
this.createForm.command = this.createCode;
// 多条件 JSON 存入 remark(统一使用新格式,后端向下兼容旧格式)
this.createForm.remark = JSON.stringify({
logic: this.createForm.conditionLogic || 'and',
conditions: validConds.map((c) => ({
identifier: c.identifier,
operator: c.operator,
value: String(c.value),
})),
});
this.createForm.cronExpression = '';
this.submitForm();
this.editDialog = false;
return;
}
// 原有的周期循环和自定义时间段逻辑
const c = this.cycles1.map((item) => {
@ -707,9 +837,72 @@ export default {
this.editDialog = false;
},
/**
* 加载属性列表供条件触发下拉使用。
* 主设备:listByProductIdAndSerialNumber,含本设备链路上可用的属性与本机/公式内自引用型计算量;
* 排除公式中含 {slaveId:N.xxx} 的跨设备计算物模型(与上报值不同路,无法作为条件比较)。
* 网关模式:另追加子产品物模型(cacheJson),同样按公式过滤跨设备计算量。
*/
loadConditionProperties() {
if (!this.device || !this.device.productId) return;
const allProperties = [];
const seen = new Set();
/** 统一格式并追加到列表(去重) */
const mergeItem = (id, name, isCalculated) => {
if (!id || seen.has(id)) return;
seen.add(id);
allProperties.push({ id, name, isCalculated: !!isCalculated });
};
// 1. 主设备:产品级 + 设备级计算物模型(type=1 属性),过滤跨设备计算量
const mainPromise = listByProductIdAndSerialNumber({
productId: this.device.productId,
serialNumber: this.device.serialNumber,
pageSize: 999,
pageNum: 1,
}).then((res) => {
(res.rows || []).forEach((item) => {
// type=1 为属性,只取属性,不取功能/事件
if (item.type === 1) {
if (isCrossDeviceCalculatedModel(item.isCalculated, item.formula)) {
return;
}
mergeItem(item.identifier, item.modelName, item.isCalculated === 1);
}
});
}).catch(() => {});
// 2. 网关模式:追加子产品物模型,过滤跨设备计算量
const subPromises = [];
if (this.isGatewayMode && this.subDeviceList && this.subDeviceList.length > 0) {
const subProductIds = [...new Set(
this.subDeviceList.map((s) => s.subProductId).filter(Boolean)
)];
subProductIds.forEach((pid) => {
subPromises.push(
cacheJsonThingsModel(pid).then((res) => {
const data = res.data ? JSON.parse(res.data) : {};
(data.properties || []).forEach((p) => {
if (isCrossDeviceCalculatedModel(p.isCalculated, p.formula)) {
return;
}
mergeItem(p.id, p.name, p.isCalculated === 1);
});
}).catch(() => {})
);
});
}
Promise.all([mainPromise, ...subPromises]).then(() => {
this.conditionProperties = allProperties;
});
},
/**打开编辑框 */
openEdit() {
this.resetCreateForm();
this.loadConditionProperties();
// 网关设备(deviceType=2)直接轮询:跳过从机地址校验,由用户手动填写
if (this.device && this.device.deviceType === 2) {
this.createForm.path = '';
@ -747,7 +940,9 @@ export default {
setValue16: '0000',
setValueSwitch: 'Dec',
status: '0',
cycleType: 1, // 1周期,2自定义
cycleType: 1, // 1周期,2自定义,3上电触发,4条件触发
conditionLogic: 'and',
conditions: [{ identifier: '', operator: '>', value: '' }],
};
this.createCode = '';
},
@ -886,6 +1081,8 @@ export default {
listGateway({ gwDeviceId: this.device.deviceId, pageSize: 999 })
.then((res) => {
this.subDeviceList = res.rows || [];
// 子设备加载完成后,刷新条件触发属性下拉(合并子设备物模型)
this.loadConditionProperties();
})
.finally(() => {
this.subListLoading = false;
@ -936,6 +1133,7 @@ export default {
// 编辑轮询任务(修改时间周期/指令等)
handleEdit(row) {
this.resetCreateForm();
this.loadConditionProperties();
this.editName = true;
getJob(row.taskId).then((res) => {
const data = (res && res.data) || {};
@ -945,11 +1143,100 @@ export default {
// 回填周期
const cycleType = data.cycleType != null ? data.cycleType : 1;
this.createForm.cycleType = cycleType;
this.parseRemarkToCycles(cycleType, data.remark);
// 回填条件触发配置(兼容新多条件格式和旧单条件格式)
if (cycleType === 4 && data.remark) {
try {
const parsed = JSON.parse(data.remark);
if (parsed.conditions && Array.isArray(parsed.conditions)) {
// 新多条件格式
this.createForm.conditionLogic = parsed.logic || 'and';
this.createForm.conditions = parsed.conditions.map((c) => ({
identifier: c.identifier || '',
operator: c.operator || '>',
value: c.value !== undefined ? c.value : '',
}));
} else {
// 旧单条件格式
this.createForm.conditionLogic = 'and';
this.createForm.conditions = [{
identifier: parsed.identifier || '',
operator: parsed.operator || '>',
value: parsed.value !== undefined ? parsed.value : '',
}];
}
} catch (e) {
this.createForm.conditionLogic = 'and';
this.createForm.conditions = [{ identifier: '', operator: '>', value: '' }];
}
} else {
this.parseRemarkToCycles(cycleType, data.remark);
}
// 打开弹窗
this.editDialog = true;
});
},
/** 列表「定时时间」列:条件触发时避免展示原始 JSON */
formatModbusScheduleDisplay(row) {
const ct = row.cycleType;
if (ct === 3) {
return '设备上电触发';
}
if (ct === 4) {
return this.formatConditionTriggerRemark(row);
}
if (row.remarkStr != null && row.remarkStr !== '') {
return row.remarkStr;
}
return '-';
},
/** 将条件触发的 remark JSON 转为可读句,如:条件触发:T > 20 且 OP3 < 11 */
formatConditionTriggerRemark(row) {
let raw = row.remark;
if (raw == null || String(raw).trim() === '') {
const rs = row.remarkStr;
if (typeof rs === 'string' && rs.indexOf('条件触发:') === 0) {
raw = rs.replace(/^条件触发:\s*/, '');
}
}
if (raw == null || String(raw).trim() === '') {
return '条件触发:—';
}
const str = String(raw).trim();
if (str.charAt(0) !== '{' && str.charAt(0) !== '[') {
return '条件触发:' + (str.length > 120 ? str.slice(0, 120) + '…' : str);
}
try {
const obj = JSON.parse(str);
if (obj.conditions && Array.isArray(obj.conditions) && obj.conditions.length) {
const isOr = obj.logic && String(obj.logic).toLowerCase() === 'or';
const sep = isOr ? ' 或 ' : ' 且 ';
const parts = obj.conditions
.map((c) => {
if (!c) {
return '';
}
const id = c.identifier != null ? c.identifier : '';
const op = c.operator != null ? c.operator : '';
const val = c.value != null ? c.value : '';
if (id === '' && op === '' && val === '') {
return '';
}
return `${id} ${op} ${val}`.replace(/\s+/g, ' ').trim();
})
.filter(Boolean);
return '条件触发:' + (parts.length ? parts.join(sep) : '—');
}
if (obj.identifier != null || obj.operator) {
const id = obj.identifier != null ? obj.identifier : '';
const op = obj.operator != null ? obj.operator : '';
const val = obj.value != null ? obj.value : '';
return '条件触发:' + `${id} ${op} ${val}`.replace(/\s+/g, ' ').trim();
}
} catch (e) {
/* 保持下方兜底 */
}
return '条件触发:' + (str.length > 100 ? str.slice(0, 100) + '…' : str);
},
// 格式化显示CRON描述
formatCronDisplay(item) {
let result = '';
@ -1024,9 +1311,21 @@ export default {
this.cycles1 = [{ interval: 'hour', time: '', week: '', day: '' }];
} else if (val === 3) {
// 设备上电触发,不需要时间周期配置
// 可以清空或保留,但不使用
} else if (val === 4) {
// 条件触发,不需要时间周期配置
if (!this.createForm.conditions || this.createForm.conditions.length === 0) {
this.createForm.conditions = [{ identifier: '', operator: '>', value: '' }];
}
}
},
/** 条件触发:新增一行条件 */
addCondition() {
this.createForm.conditions.push({ identifier: '', operator: '>', value: '' });
},
/** 条件触发:删除指定行条件 */
removeCondition(idx) {
this.createForm.conditions.splice(idx, 1);
},
handleCustomIntervalDelete(index) {
this.cycles2.splice(index, 1);
},
@ -1081,6 +1380,15 @@ $right-btn-color: #1890ff;
min-height: 400px;
}
.modbus-task-schedule-text {
white-space: normal;
line-height: 1.45;
word-break: break-word;
text-align: left;
display: inline-block;
max-width: 100%;
}
.sub-device-side-panel {
border: 1px solid $border-color;
border-radius: 4px;