安全(P0) - 发布包签名:新增 scripts/package_keygen.php、scripts/package_verify.php、 ywxapp\service\PackageVerifier;FrameworkService::apply() 解压前强制验签 (ywxapp.framework_package_verify,默认开启) - JWT 收紧:config/jwt.php 移除公开默认密钥,JwtService::assertStrongSecret() 校验非空 / 长度≥32 / 已知弱值;.env.production.example 键名由 JWT_SECRET_KEY 修正为 JWT_SECRET - /api/v1/Article:免登录白名单收窄为只读接口,写操作字段白名单 + assertOwner() 越权校验 - 开发者令牌:查找接口不再回显明文、统一失败文案 + 限流 - TemplateInstaller:文件名白名单 + realpath 归一化 + 目录内断言,修复 Zip Slip;补 use ZipArchive - 后台登录:接入验证码、失败锁定与登录限流(config/ratelimit.php) - 缓存 / Session 驱动可由 .env 覆盖(CACHE_DRIVER / SESSION_TYPE),集群部署改 redis - paycenter 支付日志开关改为 PAY_LOG,不再跟随 APP_DEBUG 工程化 / 交付 - 新增 CHANGELOG.md 与 .env.production.example;composer.lock 入库锁定依赖版本 - 插件离线安装 CLI 增加 --uid,后台上传安装透传 uid,付费插件授权校验可通过 - 授权拒绝文案区分「未携带 uid」与「未找到有效授权」,便于排障 - .gitignore 放行 .env.production.example / .env.addon.example / .gitattributes / public/.htaccess 结构 - appmall 由 addon/appmall 迁移为独立应用 app/appmall(市场 / 开发者中心 / 审核 / 分润)
203 lines
13 KiB
HTML
203 lines
13 KiB
HTML
<div class="layui-container">
|
|
<div class="admin-title">场景联动</div>
|
|
<div class="layui-text" style="margin-bottom:10px;color:#999;">
|
|
智慧生活自动化:设备状态(如湿度>60)→ 动作(下发命令/HTTP回调/站内告警);支持定时(cron)与手动触发。
|
|
配置约 30s 后在 Broker 生效。
|
|
</div>
|
|
<div style="margin-bottom:10px;">
|
|
<button class="layui-btn layui-btn-sm" onclick="editScene()">+ 新建场景</button>
|
|
</div>
|
|
<table id="sceneTable" lay-filter="sceneTable"></table>
|
|
</div>
|
|
|
|
<!-- 行操作按钮模板 -->
|
|
<script type="text/html" id="barScene">
|
|
<a class="layui-btn layui-btn-xs" lay-event="edit">编辑</a>
|
|
<a class="layui-btn layui-btn-xs layui-btn-normal" lay-event="run">执行</a>
|
|
<a class="layui-btn layui-btn-xs layui-btn-primary" lay-event="log">日志</a>
|
|
{{# if(d.enabled){ }}
|
|
<a class="layui-btn layui-btn-xs layui-btn-warm" lay-event="toggle">停用</a>
|
|
{{# } else { }}
|
|
<a class="layui-btn layui-btn-xs layui-btn-warm" lay-event="toggle">启用</a>
|
|
{{# } }}
|
|
<a class="layui-btn layui-btn-xs layui-btn-danger" lay-event="del">删除</a>
|
|
</script>
|
|
|
|
<script>
|
|
layui.use(['layer', 'jquery', 'form', 'table', 'http', 'ywxapp'], function(){
|
|
var http = layui.http;
|
|
var $ = layui.jquery, layer = layui.layer, form = layui.form, table = layui.table;
|
|
var devices = {$devicesJson|raw};
|
|
var scenes = {$scenesJson|raw};
|
|
function esc(s){ return String(s==null?'':s).replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<'); }
|
|
function fmtTime(ts){ if(!ts){return '—';} var d=new Date(Number(ts)*1000),p=function(n){return (n<10?'0':'')+n;}; return d.getFullYear()+'-'+p(d.getMonth()+1)+'-'+p(d.getDate())+' '+p(d.getHours())+':'+p(d.getMinutes()); }
|
|
function reload(){ table.reload('sceneTable'); }
|
|
function ok(res, then){ if(res.code===0){ then(); } else { layer.msg(res.message||'操作失败'); } }
|
|
|
|
// 触发器类型切换:schedule 显示 Cron,其余显示主题过滤
|
|
function renderTrgType(val){
|
|
if(val==='schedule'){ $('#trgCronWrap').show(); $('#trgStateWrap').hide(); }
|
|
else { $('#trgStateWrap').show(); $('#trgCronWrap').hide(); }
|
|
}
|
|
form.on('select(trgTypeSel)', function(obj){ renderTrgType(obj.value); });
|
|
|
|
// ================= 表格(本地数据渲染) =================
|
|
table.render({
|
|
elem: '#sceneTable',
|
|
data: scenes || [],
|
|
page: false,
|
|
limit: 100,
|
|
cols: [[
|
|
{ field: 'id', title: 'ID', width: 60, align: 'center' },
|
|
{ field: 'title', title: '场景名', minWidth: 140 },
|
|
{ field: 'icon', title: '图标', width: 80, align: 'center' },
|
|
{ field: 'trigger_count', title: '触发器', width: 90, align: 'center' },
|
|
{ field: 'action_count', title: '动作数', width: 90, align: 'center' },
|
|
{ field: 'enabled', title: '启用', width: 90, align: 'center', templet: function (d) {
|
|
return d.enabled ? '<span class="layui-badge layui-bg-green">启用</span>' : '<span class="layui-badge">停用</span>';
|
|
} },
|
|
{ field: 'create_at', title: '创建时间', width: 160, templet: function (d) { return fmtTime(d.create_at); } },
|
|
{ title: '操作', width: 300, align: 'center', toolbar: '#barScene' }
|
|
]]
|
|
});
|
|
|
|
table.on('tool(sceneTable)', function (obj) {
|
|
var d = obj.data;
|
|
if (obj.event === 'edit') {
|
|
http.get('/iot/backend/sceneDetail', {id:d.id}).then(function(res){
|
|
if(res.code===0){ editSceneForm(res.data); }
|
|
else { layer.msg(res.message||'加载失败'); }
|
|
});
|
|
} else if (obj.event === 'run') { runScene(d.id); }
|
|
else if (obj.event === 'log') { showLogs(d); }
|
|
else if (obj.event === 'toggle') { toggleScene(d.id, d.enabled ? 0 : 1); }
|
|
else if (obj.event === 'del') { delScene(d.id); }
|
|
});
|
|
|
|
// ================= 新建 / 编辑 =================
|
|
window.editScene = function(){ editSceneForm({}); };
|
|
|
|
function editSceneForm(row){
|
|
row = row || {};
|
|
var trig = (row.triggers&&row.triggers.length)?row.triggers[0]:{type:'device_state',topic:'/devices/+/state'};
|
|
var actions = row.actions||[{type:'send_cmd'}];
|
|
var actHtml = '';
|
|
actions.forEach(function(a,i){
|
|
actHtml += '<div class="layui-form-item">'
|
|
+ '<label class="layui-form-label">动作'+(i+1)+'</label>'
|
|
+ '<div class="layui-input-inline" style="width:110px;"><select name="act_type">'
|
|
+ '<option value="send_cmd" '+(a.type=='send_cmd'?'selected':'')+'>下发命令</option>'
|
|
+ '<option value="http" '+(a.type=='http'?'selected':'')+'>HTTP回调</option>'
|
|
+ '<option value="notify" '+(a.type=='notify'?'selected':'')+'>站内告警</option>'
|
|
+ '</select></div>'
|
|
+ '<div class="layui-input-inline" style="width:130px;"><select name="act_uuid"><option value="">选择设备</option>';
|
|
devices.forEach(function(dev){
|
|
actHtml += '<option value="'+esc(dev.uuid)+'" '+(a.device_uuid==dev.uuid?'selected':'')+'>'+esc(dev.title)+' ('+esc(dev.model)+')</option>';
|
|
});
|
|
actHtml += '</select></div>'
|
|
+ '<div class="layui-input-inline" style="width:150px;"><input name="act_payload" class="layui-input" value="'+esc(a.payload_json)+'" placeholder="命令JSON/HTTP体/告警内容"></div>'
|
|
+ '<div class="layui-input-inline" style="width:180px;"><input name="act_topic" class="layui-input" value="'+esc(a.topic)+'" placeholder="http回调URL/告警标题"></div>'
|
|
+ '<button type="button" class="layui-btn layui-btn-xs layui-btn-danger" onclick="$(this).closest(\'.layui-form-item\').remove()">删</button>'
|
|
+ '</div>';
|
|
});
|
|
var html = '<form class="layui-form" style="padding:20px;" lay-filter="sceneForm">'
|
|
+ '<input type="hidden" name="id" value="'+(row.id||'')+'">'
|
|
+ '<div class="layui-form-item"><label class="layui-form-label">场景名</label><div class="layui-input-inline"><input name="title" class="layui-input" value="'+esc(row.title)+'" required></div></div>'
|
|
+ '<div class="layui-form-item"><label class="layui-form-label">图标</label><div class="layui-input-inline"><input name="icon" class="layui-input" value="'+esc(row.icon)+'" placeholder="如:☀ / 🌙"></div></div>'
|
|
+ '<div class="layui-form-item"><label class="layui-form-label">触发器</label><div class="layui-input-inline" style="width:110px;"><select name="trg_type" lay-filter="trgTypeSel"><option value="device_state" '+(trig.type=='device_state'?'selected':'')+'>设备状态</option><option value="schedule" '+(trig.type=='schedule'?'selected':'')+'>定时</option><option value="manual" '+(trig.type=='manual'?'selected':'')+'>手动</option></select></div>'
|
|
+ '<div class="layui-input-inline" id="trgStateWrap"><label class="layui-form-label" style="width:auto;">主题</label><input name="trg_topic" class="layui-input" style="display:inline-block;width:200px;" value="'+esc(trig.topic)+'" placeholder="/devices/+/state"></div>'
|
|
+ '<div class="layui-input-inline" id="trgCronWrap" style="display:none;"><label class="layui-form-label" style="width:auto;">Cron</label><input name="trg_cron" class="layui-input" style="display:inline-block;width:180px;" value="'+esc(trig.cron)+'" placeholder="0 8 * * *"></div>'
|
|
+ '</div>'
|
|
+ '<div class="layui-form-item"><label class="layui-form-label">条件JSON</label><div class="layui-input-block"><textarea name="trg_condition" class="layui-textarea" rows="3" placeholder=\'[{"field":"temperature","op":"gt","value":60}]\'>'+esc(trig.condition_json)+'</textarea></div></div>'
|
|
+ actHtml
|
|
+ '<div class="layui-form-item"><div class="layui-input-block"><button type="button" class="layui-btn layui-btn-sm layui-btn-normal" onclick="addSceneAct(this)">+ 添加动作</button></div></div>'
|
|
+ '</form>';
|
|
layer.open({type:1, title:(row.id?'编辑':'新建')+'场景', area:['760px','auto'], content:html, btn:['保存','取消'],
|
|
success:function(){ layui.form.render(); renderTrgType($('[lay-filter=sceneForm] [name=trg_type]').val()); },
|
|
yes:function(idx){
|
|
var data = {};
|
|
$('[lay-filter=sceneForm]').serializeArray().forEach(function(i){ data[i.name]=i.value; });
|
|
if(!data.title){ layer.msg('请填写场景名'); return; }
|
|
var trg = {type:data.trg_type, topic:data.trg_topic, cron:data.trg_cron,
|
|
condition_json: data.trg_condition, enabled:1};
|
|
var acts = [];
|
|
$('[lay-filter=sceneForm] [name=act_type]').each(function(i){
|
|
acts.push({
|
|
type:$(this).val(),
|
|
device_uuid: $('[lay-filter=sceneForm] [name=act_uuid]').eq(i).val()||'',
|
|
payload_json: $('[lay-filter=sceneForm] [name=act_payload]').eq(i).val()||'',
|
|
topic: $('[lay-filter=sceneForm] [name=act_topic]').eq(i).val()||''
|
|
});
|
|
});
|
|
data.triggers = JSON.stringify([trg]);
|
|
data.actions = JSON.stringify(acts);
|
|
http.post('/iot/backend/saveScene', data).then(function(res){
|
|
layer.msg(res.message||res.msg||'ok');
|
|
ok(res, function(){ layer.close(idx); setTimeout(reload, 600); });
|
|
});
|
|
}
|
|
});
|
|
}
|
|
window.addSceneAct = function(btn){
|
|
var opts = '';
|
|
devices.forEach(function(dev){ opts += '<option value="'+esc(dev.uuid)+'">'+esc(dev.title)+' ('+esc(dev.model)+')</option>'; });
|
|
var $frag = $('<div class="layui-form-item">'
|
|
+ '<label class="layui-form-label">动作</label>'
|
|
+ '<div class="layui-input-inline" style="width:110px;"><select name="act_type"><option value="send_cmd" selected>下发命令</option><option value="http">HTTP回调</option><option value="notify">站内告警</option></select></div>'
|
|
+ '<div class="layui-input-inline" style="width:130px;"><select name="act_uuid"><option value="">选择设备</option>'+opts+'</select></div>'
|
|
+ '<div class="layui-input-inline" style="width:150px;"><input name="act_payload" class="layui-input" placeholder="命令JSON/HTTP体/告警内容"></div>'
|
|
+ '<div class="layui-input-inline" style="width:180px;"><input name="act_topic" class="layui-input" placeholder="http回调URL/告警标题"></div>'
|
|
+ '<button type="button" class="layui-btn layui-btn-xs layui-btn-danger" onclick="$(this).closest(\'.layui-form-item\').remove()">删</button>'
|
|
+ '</div>');
|
|
$(btn).closest('.layui-form').append($frag);
|
|
layui.form.render();
|
|
};
|
|
|
|
// ================= 立即执行 =================
|
|
function runScene(id){
|
|
layer.confirm('立即执行该场景(手动触发)?', function(idx){
|
|
http.post('/iot/backend/runScene', {id:id}).then(function(res){
|
|
layer.msg(res.message||res.msg||'已触发'); layer.close(idx);
|
|
});
|
|
});
|
|
}
|
|
|
|
// ================= 启停 =================
|
|
function toggleScene(id, enabled){
|
|
http.post('/iot/backend/toggleScene', {id:id, enabled:enabled}).then(function(res){
|
|
layer.msg(res.message||res.msg||'已更新');
|
|
setTimeout(reload, 600);
|
|
});
|
|
}
|
|
|
|
// ================= 日志 =================
|
|
function showLogs(row){
|
|
http.get('/iot/backend/sceneLogs', {id:row.id, page:1}).then(function(res){
|
|
var d = res.data||{list:[], total:0};
|
|
var rows = d.list||[];
|
|
var html = '';
|
|
rows.forEach(function(l){
|
|
html += '<tr><td>'+l.id+'</td><td>'+l.trigger_type+'</td>'
|
|
+ '<td>'+(l.matched?'<span class="layui-badge layui-bg-green">命中</span>':'<span class="layui-badge layui-bg-gray">未命中</span>')+'</td>'
|
|
+ '<td>'+esc(l.message)+'</td><td>'+fmtTime(l.create_at)+'</td></tr>';
|
|
});
|
|
if(!rows.length){ html = '<tr><td colspan="5" style="text-align:center;color:#aaa;">暂无日志</td></tr>'; }
|
|
layer.open({type:1, title:'场景执行日志:'+esc(row.title)+'(共 '+d.total+' 条,显示最近 '+rows.length+' 条)', area:['720px','420px'],
|
|
content:'<table class="layui-table" style="margin:0;"><thead><tr><th>ID</th><th>触发</th><th>结果</th><th>说明</th><th>时间</th></tr></thead><tbody>'+html+'</tbody></table>',
|
|
btn:['关闭']});
|
|
});
|
|
}
|
|
|
|
// ================= 删除 =================
|
|
function delScene(id){
|
|
layer.confirm('确认删除该场景及其触发器/动作?', function(idx){
|
|
http.post('/iot/backend/deleteScene', {id:id}).then(function(res){
|
|
layer.msg(res.message||res.msg||'已删除');
|
|
setTimeout(reload, 600);
|
|
});
|
|
layer.close(idx);
|
|
});
|
|
}
|
|
});
|
|
</script>
|