feat: 上线前安全加固与发布工程化;appmall 由插件迁移为独立应用

安全(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(市场 / 开发者中心 / 审核 / 分润)
This commit is contained in:
yangwx committed 2026-10-22 00:53:39 +08:00
1 parent 1d49e6f5ee
commit 18101cf11a
636 files changed
+58517 -9519

No files matched your search

+114
View File
@@ -0,0 +1,114 @@
<div class="layui-container">
<div class="admin-title">ACL 权限规则</div>
<div class="layui-text" style="margin-bottom:10px;color:#999;">
需在"服务设置"中开启 ACL 才生效。按 sort 升序匹配,命中第一条即决定放行/拒绝;
主题支持通配符 <code>+ / #</code> 与占位符 <code>%u</code>(用户名) <code>%c</code>(clientId)。
</div>
<div class="layui-text" style="margin-bottom:10px;padding:8px 12px;background:#fff8e6;border-left:3px solid #ffb800;color:#8a6d3b;">
<b>注意:本表管不到设备私有主题。</b>
<code>/devices/{uuid}/...</code> 与 <code>/notify/{account}/...</code> 由服务端按<b>设备绑定与共享关系</b>硬校验
(仅设备本体、设备主人、被共享者可访问;只读共享仅可订阅 state/status/event/config/ack/ota/report),
<b>与"启用ACL"开关和默认策略无关</b>,未绑定的会员始终无法订阅他人设备。
跨设备通配符(如 <code>/devices/+/state</code>)默认拒绝,如需给监控账号放行,在此为它单独加一条 <code>allow</code> 规则即可。
</div>
<div style="margin-bottom:10px;">
<button class="layui-btn layui-btn-sm" onclick="editAcl()">+ 新增规则</button>
</div>
<table id="aclTable" lay-filter="aclTable"></table>
</div>
<!-- 行操作按钮模板 -->
<script type="text/html" id="barAcl">
<a class="layui-btn layui-btn-xs" lay-event="edit">编辑</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, table = layui.table;
function esc(s){ return String(s==null?'':s).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;'); }
function reload(){ table.reload('aclTable'); }
function ok(res, then){ if(res.code===0){ then(); } else { layer.msg(res.message||'操作失败'); } }
// ================= 表格 =================
table.render({
elem: '#aclTable',
url: '/iot/backend/aclTable',
page: true,
limit: 20,
limits: [10, 20, 50, 100],
parseData: function (res) {
return {
code: res.code,
msg: res.message || res.msg || '',
count: (res.data && res.data.count) || 0,
data: (res.data && res.data.list) || []
};
},
cols: [[
{ field: 'sort', title: 'sort', width: 70, align: 'center' },
{ field: 'target', title: '目标', minWidth: 130, templet: function (d) {
var t = {all:'全部', user:'用户名', client:'clientId'}[d.target_type] || d.target_type;
return d.target_type === 'all' ? t : esc(t + ':' + d.target);
} },
{ field: 'topic', title: '主题过滤器', minWidth: 200 },
{ field: 'access', title: '权限', width: 110, align: 'center', templet: function (d) {
return d.access==1 ? '订阅' : (d.access==2 ? '发布' : '订阅+发布');
} },
{ field: 'allow', title: '动作', width: 90, align: 'center', templet: function (d) {
return d.allow ? '<span class="layui-badge layui-bg-green">允许</span>' : '<span class="layui-badge">拒绝</span>';
} },
{ field: 'remark', title: '备注', minWidth: 140 },
{ title: '操作', width: 150, align: 'center', toolbar: '#barAcl' }
]]
});
table.on('tool(aclTable)', function (obj) {
if (obj.event === 'edit') { editAcl(obj.data); }
else if (obj.event === 'del') { delAcl(obj.data.id); }
});
// ================= 新增 / 编辑 =================
window.editAcl = function(row){
row = row || {};
var sel = function(name, val, opts){
var s = '<select name="'+name+'">';
opts.forEach(function(o){ s += '<option value="'+o[0]+'" '+(String(val)==String(o[0])?'selected':'')+'>'+o[1]+'</option>'; });
return s + '</select>';
};
var html = '<form class="layui-form" style="padding:20px;" lay-filter="aclForm">'
+ '<input type="hidden" name="id" value="'+(row.id||'')+'">'
+ '<div class="layui-form-item"><label class="layui-form-label">目标类型</label><div class="layui-input-inline">'+sel('target_type', row.target_type||'all', [['all','全部'],['user','按用户名'],['client','按clientId']])+'</div></div>'
+ '<div class="layui-form-item"><label class="layui-form-label">目标值</label><div class="layui-input-inline"><input name="target" class="layui-input" value="'+esc(row.target)+'" placeholder="target_type=all 时留空"></div></div>'
+ '<div class="layui-form-item"><label class="layui-form-label">主题过滤器</label><div class="layui-input-inline"><input name="topic" class="layui-input" value="'+esc(row.topic)+'" placeholder="如 device/%c/# 或 sensor/+/temp"></div></div>'
+ '<div class="layui-form-item"><label class="layui-form-label">权限</label><div class="layui-input-inline">'+sel('access', row.access||3, [[1,'订阅'],[2,'发布'],[3,'订阅+发布']])+'</div></div>'
+ '<div class="layui-form-item"><label class="layui-form-label">动作</label><div class="layui-input-inline">'+sel('allow', (row.allow===undefined?1:row.allow), [[1,'允许'],[0,'拒绝']])+'</div></div>'
+ '<div class="layui-form-item"><label class="layui-form-label">排序</label><div class="layui-input-inline"><input name="sort" class="layui-input" value="'+(row.sort||0)+'"></div></div>'
+ '<div class="layui-form-item"><label class="layui-form-label">备注</label><div class="layui-input-inline"><input name="remark" class="layui-input" value="'+esc(row.remark)+'"></div></div>'
+ '</form>';
layer.open({type:1, title:(row.id?'编辑':'新增')+'规则', area:['520px','auto'], content:html, btn:['保存','取消'],
success:function(){ layui.form.render(); },
yes:function(idx){
var data = {};
$('[lay-filter=aclForm]').serializeArray().forEach(function(i){ data[i.name]=i.value; });
http.post('/iot/backend/saveAcl', data).then(function(res){
layer.msg(res.message||res.msg||'ok');
ok(res, function(){ layer.close(idx); setTimeout(reload, 600); });
});
}
});
};
// ================= 删除 =================
window.delAcl = function(id){
layer.confirm('确认删除该规则?', function(idx){
http.post('/iot/backend/deleteAcl', {id:id}).then(function(res){
layer.msg(res.message||'已删除');
setTimeout(reload, 600);
});
layer.close(idx);
});
};
});
</script>
+94
View File
@@ -0,0 +1,94 @@
<div class="layui-container">
<div class="admin-title">告警中心</div>
<div class="layui-text" style="margin-bottom:10px;color:#999;">
设备离线、场景联动、OTA 失败等事件统一汇聚;支持按未读过滤与单条/一键已读。
</div>
<div style="margin-bottom:10px;">
<button class="layui-btn layui-btn-sm" id="btnUnread">只看未读</button>
<button class="layui-btn layui-btn-sm layui-btn-normal" id="btnAllRead">全部已读</button>
</div>
<table id="alertTable" lay-filter="alertTable"></table>
</div>
<!-- 行操作按钮模板 -->
<script type="text/html" id="barAlert">
{{# if(!d.is_read){ }}
<a class="layui-btn layui-btn-xs layui-btn-normal" lay-event="read">标记已读</a>
{{# } }}
</script>
<script>
layui.use(['layer', 'jquery', 'table', 'http', 'ywxapp'], function(){
var http = layui.http;
var $ = layui.jquery, layer = layui.layer, table = layui.table;
var only = 0;
function esc(s){ return String(s==null?'':s).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;'); }
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())+':'+p(d.getSeconds()); }
function reload(){ table.reload('alertTable', { where: only ? { unread: 1 } : {} }); }
// ================= 表格 =================
table.render({
elem: '#alertTable',
url: '/iot/backend/alerts',
page: true,
limit: 20,
limits: [10, 20, 50, 100],
parseData: function (res) {
return {
code: res.code,
msg: res.message || res.msg || '',
count: (res.data && res.data.count) || 0,
data: (res.data && res.data.list) || []
};
},
cols: [[
{ field: 'id', title: 'ID', width: 60, align: 'center' },
{ field: 'level', title: '级别', width: 80, align: 'center', templet: function (d) {
var map = {3:['layui-bg-red','严重'], 2:['layui-bg-orange','警告'], 1:['layui-bg-blue','提示']};
var m = map[d.level] || ['', d.level];
return '<span class="layui-badge ' + m[0] + '">' + m[1] + '</span>';
} },
{ field: 'type', title: '类型', width: 100, templet: function (d) { return esc(d.type); } },
{ field: 'uuid', title: '设备', width: 130, templet: function (d) { return d.uuid ? esc(d.uuid) : '<span style="color:#aaa;">—</span>'; } },
{ field: 'title', title: '标题', minWidth: 140, templet: function (d) { return esc(d.title); } },
{ field: 'content', title: '内容', minWidth: 200, templet: function (d) { return esc(d.content); } },
{ field: 'member_id', title: '会员', width: 80, align: 'center', templet: function (d) {
return d.member_id ? 'M' + d.member_id : '<span style="color:#aaa;">—</span>';
} },
{ field: 'is_read', title: '状态', width: 80, align: 'center', templet: function (d) {
return d.is_read ? '<span class="layui-badge layui-bg-gray">已读</span>' : '<span class="layui-badge layui-bg-green">未读</span>';
} },
{ field: 'create_at', title: '时间', width: 155, templet: function (d) { return fmtTime(d.create_at); } },
{ title: '操作', width: 110, align: 'center', toolbar: '#barAlert' }
]]
});
table.on('tool(alertTable)', function (obj) {
if (obj.event === 'read') { readOne(obj.data.id); }
});
// ================= 单条已读 =================
function readOne(id){
http.post('/iot/backend/readAlertAll', {ids:[id]}).then(function(res){
layer.msg(res.message || '已标记');
setTimeout(reload, 400);
});
}
// ================= 只看未读 / 全部已读 =================
$('#btnUnread').on('click', function(){
only = only ? 0 : 1;
$(this).text(only ? '看全部' : '只看未读');
reload();
});
$('#btnAllRead').on('click', function(){
layer.confirm('确认将全部告警标记为已读?', function(idx){
http.post('/iot/backend/readAlertAll', {ids:[]}).then(function(res){
layer.close(idx);
layer.msg(res.message || '已标记');
setTimeout(reload, 500);
});
});
});
});
</script>
+98
View File
@@ -0,0 +1,98 @@
<div class="layui-container">
<div class="admin-title">认证账号</div>
<div class="layui-text" style="margin-bottom:10px;color:#999;">
当"允许匿名连接"关闭时,客户端必须使用此处账号连接;超级用户跳过 ACL 校验。
</div>
<div style="margin-bottom:10px;">
<button class="layui-btn layui-btn-sm" onclick="editAuth()">+ 新增账号</button>
</div>
<table id="authTable" lay-filter="authTable"></table>
</div>
<!-- 行操作按钮模板 -->
<script type="text/html" id="barAuth">
<a class="layui-btn layui-btn-xs" lay-event="edit">编辑</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, table = layui.table;
function esc(s){ return String(s==null?'':s).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;'); }
function reload(){ table.reload('authTable'); }
function ok(res, then){ if((res.code===0)||(res.code===1)){ then(); } else { layer.msg(res.message||'操作失败'); } }
// ================= 表格 =================
table.render({
elem: '#authTable',
url: '/iot/backend/authTable',
page: true,
limit: 20,
limits: [10, 20, 50, 100],
parseData: function (res) {
return {
code: res.code,
msg: res.message || res.msg || '',
count: (res.data && res.data.count) || 0,
data: (res.data && res.data.list) || []
};
},
cols: [[
{ field: 'id', title: 'ID', width: 70, align: 'center' },
{ field: 'username', title: '用户名', minWidth: 140 },
{ field: 'is_superuser', title: '超级用户', width: 100, align: 'center', templet: function (d) {
return d.is_superuser ? '<span class="layui-badge layui-bg-orange">是</span>' : '否';
} },
{ field: 'member_id', title: '会员', width: 100, align: 'center', templet: function (d) {
return d.member_id ? '<span class="layui-badge layui-bg-blue">M' + d.member_id + '</span>' : '<span style="color:#aaa;">—</span>';
} },
{ field: 'status', title: '状态', width: 90, align: 'center', templet: function (d) {
return d.status ? '<span class="layui-badge layui-bg-green">启用</span>' : '<span class="layui-badge">禁用</span>';
} },
{ field: 'remark', title: '备注', minWidth: 160 },
{ title: '操作', width: 150, align: 'center', toolbar: '#barAuth' }
]]
});
table.on('tool(authTable)', function (obj) {
if (obj.event === 'edit') { editAuth(obj.data); }
else if (obj.event === 'del') { delAuth(obj.data.id); }
});
// ================= 新增 / 编辑 =================
window.editAuth = function(row){
row = row || {};
var html = '<form class="layui-form" style="padding:20px;" lay-filter="authForm">'
+ '<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="username" class="layui-input" value="'+esc(row.username)+'"></div></div>'
+ '<div class="layui-form-item"><label class="layui-form-label">密码</label><div class="layui-input-inline"><input name="password" class="layui-input" placeholder="'+(row.id?'留空表示不修改':'必填')+'"></div></div>'
+ '<div class="layui-form-item"><label class="layui-form-label">超级用户</label><div class="layui-input-inline"><select name="is_superuser"><option value="0" '+(!row.is_superuser?'selected':'')+'>否</option><option value="1" '+(row.is_superuser==1?'selected':'')+'>是</option></select></div></div>'
+ '<div class="layui-form-item"><label class="layui-form-label">状态</label><div class="layui-input-inline"><select name="status"><option value="1" '+(row.status!=0?'selected':'')+'>启用</option><option value="0" '+(row.status==0?'selected':'')+'>禁用</option></select></div></div>'
+ '<div class="layui-form-item"><label class="layui-form-label">备注</label><div class="layui-input-inline"><input name="remark" class="layui-input" value="'+esc(row.remark)+'"></div></div>'
+ '</form>';
layer.open({type:1, title:(row.id?'编辑':'新增')+'账号', area:['480px','auto'], content:html, btn:['保存','取消'],
success:function(){ layui.form.render(); },
yes:function(idx){
var data = {};
$('[lay-filter=authForm]').serializeArray().forEach(function(i){ data[i.name]=i.value; });
http.post('/iot/backend/saveAuth', data).then(function(res){
layer.msg(res.message||res.msg||'ok');
ok(res, function(){ layer.close(idx); setTimeout(reload, 600); });
});
}
});
};
// ================= 删除 =================
window.delAuth = function(id){
layer.confirm('确认删除该账号?', function(idx){
http.post('/iot/backend/deleteAuth', {id:id}).then(function(res){
layer.msg(res.message||'已删除');
setTimeout(reload, 600);
});
layer.close(idx);
});
};
});
</script>
@@ -0,0 +1,44 @@
<div class="layui-container">
<div class="admin-title">客户端连接</div>
<div class="layui-text" style="margin-bottom:10px;color:#999;">
记录最近接入的客户端会话(含成功/失败的认证尝试);在线状态由 Broker 心跳实时刷新。
</div>
<table id="connTable" lay-filter="connTable"></table>
</div>
<script>
layui.use(['table'], function(){
var table = layui.table;
function esc(s){ return String(s==null?'':s).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;'); }
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())+':'+p(d.getSeconds()); }
table.render({
elem: '#connTable',
url: '/iot/backend/connectionsTable',
page: true,
limit: 20,
limits: [10, 20, 50, 100],
parseData: function (res) {
return {
code: res.code,
msg: res.message || res.msg || '',
count: (res.data && res.data.count) || 0,
data: (res.data && res.data.list) || []
};
},
cols: [[
{ field: 'id', title: 'ID', width: 70, align: 'center' },
{ field: 'client_id', title: 'ClientId', width: 190 },
{ field: 'username', title: '用户名', minWidth: 120 },
{ field: 'ip', title: 'IP', width: 130 },
{ field: 'status', title: '连接状态', width: 100, align: 'center', templet: function (d) {
return d.status==1 ? '<span class="layui-badge layui-bg-green">在线</span>' : '<span class="layui-badge layui-bg-gray">离线</span>';
} },
{ field: 'create_at', title: '接入时间', width: 160, templet: function (d) { return fmtTime(d.create_at); } },
{ title: '说明', minWidth: 120, templet: function (d) {
return d.client_id && d.client_id.indexOf('web_') === 0 ? 'WebSocket 客户端' : 'MQTT 客户端';
} }
]]
});
});
</script>
+297
View File
@@ -0,0 +1,297 @@
<div class="layui-container">
<div class="admin-title">智能设备</div>
<div class="layui-text" style="margin-bottom:10px;color:#999;">
华为式绑定流程:后台预登记 SN,自动生成<b>配对码</b>(机身贴纸,防抢绑)与<b>激活令牌</b>(出厂预置)→ 设备首次联网调
<code>/iot/api/deviceActivate</code> 入云激活领取 uuid/secret → 会员 App 扫码 SN + 输入配对码绑定认领。
已激活设备重新绑定时凭证不变;老设备(无配对码)仍兼容"扫码即绑"。连接约定:username=clientId=uuid,Topic:<code>/devices/{uuid}/state</code> 上报、
<code>/devices/{uuid}/cmd</code> 接收命令。
</div>
<div style="margin-bottom:10px;">
<form class="layui-form" lay-filter="devSearch" style="display:inline-block;">
<div class="layui-input-inline" style="width:220px;">
<input name="kw" class="layui-input" id="devKw" placeholder="搜索 SN / 名称 / UUID">
</div>
<button class="layui-btn layui-btn-sm" lay-submit lay-filter="devSearch">搜索</button>
</form>
<button class="layui-btn layui-btn-sm" style="margin-left:10px;" onclick="editDevice()">+ 预登记设备</button>
</div>
<table id="devTable" lay-filter="devTable"></table>
</div>
<!-- 行操作按钮模板 -->
<script type="text/html" id="barDev">
<a class="layui-btn layui-btn-xs" lay-event="edit">编辑</a>
{{# if(d.uuid){ }}
<a class="layui-btn layui-btn-xs layui-btn-primary" lay-event="cfg">传感器</a>
<a class="layui-btn layui-btn-xs layui-btn-warm" lay-event="resetSecret">重置密钥</a>
{{# } else { }}
<a class="layui-btn layui-btn-xs layui-btn-normal" lay-event="resetToken">重置激活令牌</a>
{{# } }}
{{# if(d.uuid){ }}
<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 kw = '';
function esc(s){ return String(s==null?'':s).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;'); }
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('devTable', { where: kw ? { kw: kw } : {} }); }
// ================= 搜索 =================
form.on('submit(devSearch)', function(obj){
kw = $.trim(obj.field.kw || '');
reload();
return false;
});
// ================= 表格 =================
table.render({
elem: '#devTable',
url: '/iot/backend/deviceTable',
page: true,
limit: 20,
limits: [10, 20, 50, 100],
parseData: function (res) {
return {
code: res.code,
msg: res.message || res.msg || '',
count: (res.data && res.data.count) || 0,
data: (res.data && res.data.list) || []
};
},
cols: [[
{ field: 'id', title: 'ID', width: 60, align: 'center' },
{ field: 'sn', title: '序列号', width: 110 },
{ field: 'title', title: '名称', minWidth: 110 },
{ field: 'uuid', title: 'UUID', width: 150, templet: function (d) {
return d.uuid ? '<span style="word-break:break-all;">' + esc(d.uuid) + '</span>' : '<span style="color:#aaa;">未激活</span>';
} },
{ field: 'model', title: '型号/房间', width: 120, templet: function (d) {
return esc(d.model) + (d.room ? ' / ' + esc(d.room) : '');
} },
{ field: 'pair_code', title: '配对码', width: 90, align: 'center', templet: function (d) {
return d.pair_code ? '<code>' + esc(d.pair_code) + '</code>' : '<span style="color:#aaa;">—</span>';
} },
{ field: 'member_account', title: '绑定会员', width: 130, templet: function (d) {
if (d.member_id) {
return '<span class="layui-badge layui-bg-blue">M' + d.member_id + '</span> ' + (d.member_account ? esc(d.member_account) : '<span style="color:#aaa;">(账号缺失)</span>');
}
return '<span style="color:#aaa;">未绑定</span>';
} },
{ field: 'online', title: '在线', width: 80, align: 'center', templet: function (d) {
return d.online ? '<span class="layui-badge layui-bg-green">在线</span>' : '<span class="layui-badge">离线</span>';
} },
{ field: 'status', title: '状态', width: 80, align: 'center', templet: function (d) {
return d.status ? '<span class="layui-badge layui-bg-green">启用</span>' : '<span class="layui-badge">禁用</span>';
} },
{ field: 'bind_at', title: '绑定时间', width: 145, templet: function (d) { return fmtTime(d.bind_at); } },
{ field: 'cfg_ver', title: '传感器配置', width: 120, align: 'center', templet: function (d) {
if (!d.cfg_ver) { return '<span style="color:#aaa;">未配置</span>'; }
return (Number(d.sync_ver) >= Number(d.cfg_ver))
? '<span class="layui-badge layui-bg-green">v' + d.cfg_ver + ' 已生效</span>'
: '<span class="layui-badge layui-bg-orange">v' + d.cfg_ver + ' 待生效</span>';
} },
{ title: '操作', width: 330, align: 'center', toolbar: '#barDev' }
]]
});
table.on('tool(devTable)', function (obj) {
var d = obj.data;
if (obj.event === 'edit') { editDevice(d); }
else if (obj.event === 'cfg') { cfgDevice(d); }
else if (obj.event === 'resetSecret') { resetSecret(d.uuid); }
else if (obj.event === 'resetToken') { resetToken(d.sn); }
else if (obj.event === 'del') { delDevice(d); }
});
// ================= 预登记 / 编辑 =================
window.editDevice = function(row){
row = row || {};
var tokenTip = (!row.uuid && row.activate_token)
? '<div class="layui-form-item"><label class="layui-form-label">激活令牌</label><div class="layui-input-inline" style="width:300px;"><input class="layui-input" value="'+esc(row.activate_token)+'" readonly placeholder="设备入云激活用,一次性"></div></div>'
: (row.uuid ? '<div class="layui-form-item"><label class="layui-form-label">激活状态</label><div class="layui-input-inline" style="width:300px;"><span style="line-height:38px;color:green;">已激活</span></div></div>' : '');
var html = '<form class="layui-form" style="padding:20px;" lay-filter="devForm">'
+ '<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="sn" class="layui-input" value="'+esc(row.sn)+'" '+(row.id?'readonly':'')+' placeholder="机身SN,会员扫码绑定凭证"></div></div>'
+ '<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)+'"></div></div>'
+ '<div class="layui-form-item"><label class="layui-form-label">型号</label><div class="layui-input-inline"><input name="model" class="layui-input" value="'+esc(row.model)+'"></div></div>'
+ '<div class="layui-form-item"><label class="layui-form-label">房间</label><div class="layui-input-inline"><input name="room" class="layui-input" value="'+esc(row.room)+'"></div></div>'
+ '<div class="layui-form-item"><label class="layui-form-label">配对码</label><div class="layui-input-inline"><input name="pair_code" class="layui-input" value="'+esc(row.pair_code)+'" placeholder="留空=新增时自动生成6位数字"></div></div>'
+ tokenTip
+ '<div class="layui-form-item"><label class="layui-form-label">状态</label><div class="layui-input-inline"><select name="status"><option value="1" '+(row.status!=0?'selected':'')+'>启用</option><option value="0" '+(row.status==0?'selected':'')+'>禁用</option></select></div></div>'
+ '</form>';
layer.open({type:1, title:(row.id?'编辑':'预登记')+'设备', area:['460px','auto'], content:html, btn:['保存','取消'],
success:function(){ layui.form.render(); },
yes:function(idx){
var data = {};
$('[lay-filter=devForm]').serializeArray().forEach(function(i){ data[i.name]=i.value; });
if(!row.id && !data.sn){ layer.msg('请填写设备序列号'); return; }
http.post('/iot/backend/saveDevice', data).then(function(res){
if(res.code===0){
layer.close(idx);
// 新登记:展示配对码 + 激活令牌(一次性,请写入出厂配置/贴纸)
if(!row.id && res.data && res.data.activate_token){
layer.open({type:1, title:'预登记成功(请保存以下出厂信息)', area:['520px','auto'],
content:'<div style="padding:20px;">'
+ '<p style="margin-bottom:8px;">设备序列号:<code>'+esc(data.sn)+'</code></p>'
+ '<p style="margin-bottom:8px;">配对码(机身贴纸/说明书):<code>'+esc(res.data.pair_code)+'</code></p>'
+ '<p style="margin-bottom:8px;">激活令牌(出厂预置,设备首次联网调用 /iot/api/deviceActivate 领取 uuid/secret,一次性):</p>'
+ '<p><code style="word-break:break-all;">'+esc(res.data.activate_token)+'</code></p>'
+ '</div>', btn:['我已保存','关闭'], yes:function(i){ layer.close(i); reload(); } });
return;
}
layer.msg(res.message||res.msg||'ok');
setTimeout(reload, 600);
} else {
layer.msg(res.message||'操作失败');
}
});
}
});
};
// ================= 重置激活令牌 =================
window.resetToken = function(sn){
layer.confirm('重新生成激活令牌后,旧令牌立即失效(已打印/预置的旧令牌将无法激活)。确认继续?', function(idx){
http.post('/iot/backend/resetActivateToken', {sn:sn}).then(function(res){
layer.close(idx);
if(res.code===0){
layer.open({type:1, title:'新激活令牌(仅显示一次)', area:['520px','auto'],
content:'<div style="padding:20px;">'
+ '<p style="margin-bottom:8px;">设备序列号:<code>'+esc(sn)+'</code></p>'
+ '<p style="margin-bottom:8px;">新激活令牌(出厂预置,一次性):</p>'
+ '<p><code style="word-break:break-all;">'+esc((res.data&&res.data.activate_token)||'')+'</code></p>'
+ '</div>', btn:['我已保存','关闭'], yes:function(i){ layer.close(i); } });
} else {
layer.msg(res.message||'操作失败');
}
});
});
};
// ================= 重置密钥 =================
window.resetSecret = function(uuid){
layer.confirm('重置后将作废当前密钥,设备需使用新密钥重新连接。确认继续?', function(idx){
http.post('/iot/backend/resetDeviceSecret', {uuid:uuid}).then(function(res){
layer.close(idx);
if(res.code===0){
var sec = (res.data&&res.data.secret)||'';
layer.open({type:1, title:'新密钥(仅显示一次,请立即保存)', area:['460px','auto'],
content:'<div style="padding:20px;">'
+ '<p style="margin-bottom:10px;">设备连接参数:</p>'
+ '<p style="margin-bottom:6px;">username / clientId:<code>'+esc(uuid)+'</code></p>'
+ '<p>password:<code style="word-break:break-all;">'+esc(sec)+'</code></p>'
+ '</div>', btn:['我已保存','关闭'], yes:function(i){ layer.close(i); } });
} else {
layer.msg(res.message||'操作失败');
}
});
});
};
// ================= 删除 =================
window.delDevice = function(row){
layer.confirm('删除将同时清除该设备的 MQTT 账号、ACL、影子与订阅,且不可恢复。确认删除?', function(idx){
http.post('/iot/backend/deleteDevice', {uuid:row.uuid}).then(function(res){
layer.msg(res.message||res.msg||'已删除');
setTimeout(reload, 600);
});
layer.close(idx);
});
};
// ================= 传感器配置(配置真源在服务端:保存即 retained 下发设备,App 读同一份) =================
var cfgUuid = '';
function cfgRowHtml(s){
return '<tr data-i="' + s.index + '">'
+ '<td style="text-align:center;">' + (Number(s.index) + 1) + '</td>'
+ '<td><input class="layui-input cfg-alias" value="' + esc(s.alias) + '" placeholder="如 进水"></td>'
+ '<td><input type="number" min="1" max="3" class="layui-input cfg-probes" value="' + s.probes + '"></td>'
+ '<td><input type="number" step="0.1" class="layui-input cfg-upper" value="' + s.upper + '"></td>'
+ '<td><input type="number" step="0.1" class="layui-input cfg-lower" value="' + s.lower + '"></td>'
+ '<td><input type="number" step="0.1" min="0" class="layui-input cfg-gap" value="' + s.gap + '"></td>'
+ '<td style="text-align:center;"><input type="checkbox" class="cfg-enabled" ' + (Number(s.enabled) !== 0 ? 'checked' : '') + '></td>'
+ '<td style="text-align:center;"><a class="layui-btn layui-btn-xs layui-btn-danger cfg-del">删除</a></td>'
+ '</tr>';
}
window.cfgAddPos = function(){
var $tb = $('#cfgTbody');
if ($tb.find('tr').length >= 3) { layer.msg('最多 3 个位置(对应固件 3 条 OneWire 总线)'); return; }
var used = {};
$tb.find('tr').each(function(){ used[Number($(this).data('i'))] = 1; });
var free = 0;
while (used[free]) { free++; }
$tb.append(cfgRowHtml({ index: free, alias: '位置' + (free + 1), probes: 2, upper: 85, lower: 0, gap: 0, enabled: 1 }));
};
$(document).on('click', '.cfg-del', function(){
if ($('#cfgTbody tr').length <= 1) { layer.msg('至少保留一个位置'); return; }
$(this).closest('tr').remove();
});
window.cfgDevice = function(row){
if (!row.uuid) { layer.msg('设备未激活(无 uuid),无法配置'); return; }
cfgUuid = row.uuid;
http.get('/iot/backend/deviceConfig', { uuid: cfgUuid }).then(function(res){
if (res.code !== 0) { layer.msg(res.message || '读取配置失败'); return; }
var cfg = res.data || {};
var list = (cfg.sensors && cfg.sensors.length) ? cfg.sensors
: [{ index: 0, alias: '位置1', probes: 2, upper: 85, lower: 0, gap: 0, enabled: 1 }];
var rows = '';
list.forEach(function(s){ rows += cfgRowHtml(s); });
var state = cfg.applied
? '<span class="layui-badge layui-bg-green">已生效(设备 v' + cfg.sync_ver + ')</span>'
: '<span class="layui-badge layui-bg-orange">待设备上线生效(服务端 v' + cfg.cfg_ver + ' / 设备 v' + cfg.sync_ver + ')</span>';
var html = '<div style="padding:16px 18px;">'
+ '<div style="margin-bottom:10px;color:#666;line-height:22px;">'
+ '设备:<b>' + esc(row.title || row.sn) + '</b> &nbsp;|&nbsp; 配置版本:<b>v' + cfg.cfg_ver + '</b> &nbsp;|&nbsp; 同步状态:' + state
+ '<div style="margin-top:6px;color:#999;">配置保存在服务端(唯一真源):保存后立即入 retained 队列,设备在线秒级生效、离线则上线订阅瞬间收到。App 读同一份配置渲染面板,state 报文只传数值不传配置。</div>'
+ '</div>'
+ '<table class="layui-table" style="margin:0;"><thead><tr>'
+ '<th style="width:52px;text-align:center;">序号</th><th>位置名称</th><th style="width:80px;">探头数</th>'
+ '<th style="width:90px;">超温上限</th><th style="width:90px;">低温下限</th><th style="width:90px;">温差阈值</th>'
+ '<th style="width:60px;text-align:center;">启用</th><th style="width:64px;text-align:center;">操作</th>'
+ '</tr></thead><tbody id="cfgTbody">' + rows + '</tbody></table>'
+ '<div style="margin-top:10px;">'
+ '<a class="layui-btn layui-btn-sm" onclick="cfgAddPos()">+ 添加位置</a>'
+ '<span style="margin-left:10px;color:#999;">温差阈值 = 同一位置内各探头极差告警(0=关闭);上限/下限为逐颗探头比对</span>'
+ '</div></div>';
layer.open({ type:1, title:'传感器位置配置', area:['880px','auto'], content:html, btn:['保存并下发','关闭'],
yes:function(idx){
var sensors = [], bad = '';
$('#cfgTbody tr').each(function(){
var $t = $(this);
var s = {
index: Number($t.data('i')),
alias: $.trim($t.find('.cfg-alias').val()),
probes: Number($t.find('.cfg-probes').val()),
upper: Number($t.find('.cfg-upper').val()),
lower: Number($t.find('.cfg-lower').val()),
gap: Number($t.find('.cfg-gap').val()),
enabled: $t.find('.cfg-enabled').prop('checked') ? 1 : 0
};
if (!s.alias) { bad = '位置名称不能为空'; }
if (!(s.probes >= 1 && s.probes <= 3)) { bad = '探头数须为 1~3'; }
if (isNaN(s.upper) || isNaN(s.lower)) { bad = '上限/下限须为数字'; }
if (isNaN(s.gap) || s.gap < 0) { bad = '温差阈值须为不小于 0 的数字'; }
sensors.push(s);
});
if (bad) { layer.msg(bad); return; }
http.post('/iot/backend/saveDeviceConfig', { uuid: cfgUuid, sensors: JSON.stringify(sensors) }).then(function(r2){
if (r2.code === 0) {
layer.msg(r2.message || '已保存并下发');
layer.close(idx);
setTimeout(reload, 600);
} else {
layer.msg(r2.message || '保存失败');
}
});
}
});
});
};
});
</script>
+94
View File
@@ -0,0 +1,94 @@
<div class="layui-container">
<div class="admin-title">设备分组</div>
<div class="layui-text" style="margin-bottom:10px;color:#999;">
后台可维护全局分组(所有会员可见);会员自己的分组由 App 端管理。
</div>
<div style="margin-bottom:10px;">
<button class="layui-btn layui-btn-sm" onclick="editGroup()">+ 新建分组</button>
</div>
<table id="groupTable" lay-filter="groupTable"></table>
</div>
<!-- 行操作按钮模板 -->
<script type="text/html" id="barGroup">
<a class="layui-btn layui-btn-xs" lay-event="edit">编辑</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, table = layui.table;
var groups = {$groupsJson|raw};
function esc(s){ return String(s==null?'':s).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;'); }
function reload(){ table.reload('groupTable'); }
function ok(res, then){ if(res.code===0){ then(); } else { layer.msg(res.message||'操作失败'); } }
// ================= 表格(本地数据渲染) =================
table.render({
elem: '#groupTable',
data: groups || [],
page: false,
limit: 100,
cols: [[
{ field: 'id', title: 'ID', width: 60, align: 'center' },
{ field: 'title', title: '分组名', minWidth: 140 },
{ field: 'type', title: '类型', width: 90, align: 'center', templet: function (d) {
return d.type=='room' ? '房间' : '自定义';
} },
{ field: 'member_id', title: '归属', width: 90, align: 'center', templet: function (d) {
return d.member_id>0 ? 'M'+d.member_id : '<span class="layui-badge layui-bg-blue">全局</span>';
} },
{ field: 'device_count', title: '设备数', width: 90, align: 'center' },
{ field: 'online_count', title: '在线', width: 80, align: 'center' },
{ field: 'sort', title: '排序', width: 80, align: 'center' },
{ title: '操作', width: 150, align: 'center', toolbar: '#barGroup' }
]]
});
table.on('tool(groupTable)', function (obj) {
if (obj.event === 'edit') { editGroup(obj.data); }
else if (obj.event === 'del') { delGroup(obj.data.id); }
});
// ================= 新建 / 编辑 =================
window.editGroup = function(row){
row = row || {};
var html = '<form class="layui-form" style="padding:20px;" lay-filter="groupForm">'
+ '<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"><select name="type">'
+ '<option value="room" '+(row.type=='room'?'selected':'')+'>房间</option>'
+ '<option value="custom" '+(row.type=='custom'?'selected':'')+'>自定义</option>'
+ '</select></div></div>'
+ '<div class="layui-form-item"><label class="layui-form-label">排序</label>'
+ '<div class="layui-input-inline"><input name="sort" class="layui-input" value="'+(row.sort||0)+'"></div></div>'
+ '</form>';
layer.open({type:1, title:(row.id?'编辑':'新建')+'分组', area:['460px','auto'], content:html, btn:['保存','取消'],
success:function(){ layui.form.render(); },
yes:function(idx){
var data = {};
$('[lay-filter=groupForm]').serializeArray().forEach(function(i){ data[i.name]=i.value; });
if(!data.title){ layer.msg('请填写分组名'); return; }
http.post('/iot/backend/saveGroup', data).then(function(res){
layer.msg(res.message||res.msg||'ok');
ok(res, function(){ layer.close(idx); setTimeout(reload, 600); });
});
}
});
};
// ================= 删除 =================
window.delGroup = function(id){
layer.confirm('确认删除该分组及关联?', function(idx){
http.post('/iot/backend/deleteGroup', {id:id}).then(function(res){
layer.msg(res.message||res.msg||'已删除');
setTimeout(reload, 600);
});
layer.close(idx);
});
};
});
</script>
+101
View File
@@ -0,0 +1,101 @@
<div class="layui-container">
<div class="admin-title">MQTT 运行概览
<span class="layui-badge layui-bg-blue" id="live" style="margin-left:10px;">实时</span>
<span style="float:right;color:#999;font-size:12px;">最后刷新:<span id="lastSync">--</span></span>
</div>
<div class="layui-row layui-col-space15">
<div class="layui-col-md3">
<div class="layui-card"><div class="layui-card-header">在线连接</div>
<div class="layui-card-body" style="font-size:30px;font-weight:bold;color:#1E9FFF;" id="m_online">0</div></div>
</div>
<div class="layui-col-md3">
<div class="layui-card"><div class="layui-card-header">会话总数</div>
<div class="layui-card-body" style="font-size:30px;font-weight:bold;" id="m_total">0</div></div>
</div>
<div class="layui-col-md3">
<div class="layui-card"><div class="layui-card-header">订阅关系</div>
<div class="layui-card-body" style="font-size:30px;font-weight:bold;color:#16a34a;" id="m_subs">0</div></div>
</div>
<div class="layui-col-md3">
<div class="layui-card"><div class="layui-card-header">运行时长</div>
<div class="layui-card-body" style="font-size:30px;font-weight:bold;" id="m_uptime">00:00:00</div></div>
</div>
</div>
<div class="layui-row layui-col-space15">
<div class="layui-col-md3">
<div class="layui-card"><div class="layui-card-header">累计接收</div>
<div class="layui-card-body" style="font-size:26px;font-weight:bold;" id="m_recv">0</div></div>
</div>
<div class="layui-col-md3">
<div class="layui-card"><div class="layui-card-header">累计发送</div>
<div class="layui-card-body" style="font-size:26px;font-weight:bold;" id="m_sent">0</div></div>
</div>
<div class="layui-col-md3">
<div class="layui-card"><div class="layui-card-header">保留消息</div>
<div class="layui-card-body" style="font-size:26px;font-weight:bold;" id="m_retained">0</div></div>
</div>
<div class="layui-col-md3">
<div class="layui-card"><div class="layui-card-header">快照时间</div>
<div class="layui-card-body" style="font-size:18px;" id="m_ts">--</div></div>
</div>
</div>
<div class="admin-title">最近消息</div>
<table id="recentTable" lay-filter="recentTable"></table>
<div class="layui-text" style="margin-top:15px;color:#999;">
指标由 Broker 进程每 2 秒写入数据库,本页每 3 秒轮询刷新(跨进程安全,Windows 调试与 Linux 生产通用)。
若长时间显示 0,请确认 Broker 已启动:<code>php think iot start -d</code>(需在命令行运行常驻进程)。
</div>
</div>
<script>
layui.use(['jquery', 'table'], function(){
var $ = layui.jquery, table = layui.table;
var recent = {$recentJson|raw};
function esc(s){ return String(s==null?'':s).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;'); }
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())+':'+p(d.getSeconds()); }
table.render({
elem: '#recentTable',
data: recent || [],
page: false,
limit: 10,
cols: [[
{ field: 'id', title: 'ID', width: 80, align: 'center' },
{ field: 'topic', title: '主题', minWidth: 180, templet: function (d) { return esc(d.topic); } },
{ field: 'source', title: '来源', width: 100, templet: function (d) { return esc(d.source); } },
{ field: 'payload', title: '内容', minWidth: 300, templet: function (d) { return esc(d.payload); } },
{ field: 'create_at', title: '时间', width: 170, templet: function (d) { return fmtTime(d.create_at); } }
]]
});
function fmtUptime(s){
s = parseInt(s||0);
var h = Math.floor(s/3600), m = Math.floor((s%3600)/60), ss = s%60;
var p = function(n){ return (n<10?'0':'')+n; };
return p(h)+':'+p(m)+':'+p(ss);
}
function load(){
$.getJSON('/iot/backend/statsData', function(res){
if (res.code !== 0 || !res.data) return;
var d = res.data;
$('#m_online').text(d.db_online != null ? d.db_online : d.clients_online);
$('#m_total').text(d.clients_total);
$('#m_subs').text(d.subscriptions);
$('#m_uptime').text(fmtUptime(d.uptime));
$('#m_recv').text(d.messages_received);
$('#m_sent').text(d.messages_sent);
$('#m_retained').text(d.retained);
$('#m_ts').text(new Date((d.timestamp||0)*1000).toLocaleString());
$('#lastSync').text(new Date().toLocaleTimeString());
$('#live').text('实时●');
}).fail(function(){ $('#live').text('断开'); });
}
load();
setInterval(load, 3000);
});
</script>
@@ -0,0 +1,65 @@
<div class="layui-container">
<div class="admin-title">消息日志</div>
<div class="layui-text" style="margin-bottom:10px;color:#999;">
最近经 Broker 转发的消息样本(日志有上限,超过即滚动覆盖);可输入主题关键词过滤。
</div>
<div style="margin-bottom:10px;">
<form class="layui-form" lay-filter="msgSearch" style="display:inline-block;">
<div class="layui-input-inline" style="width:240px;">
<input name="topic" class="layui-input" placeholder="按主题过滤,如 /devices/…">
</div>
<button class="layui-btn layui-btn-sm" lay-submit lay-filter="msgSearch">查询</button>
</form>
<button class="layui-btn layui-btn-sm layui-btn-normal" style="margin-left:10px;" id="btnReset">重置</button>
</div>
<table id="msgTable" lay-filter="msgTable"></table>
</div>
<script>
layui.use(['jquery', 'form', 'table'], function(){
var $ = layui.jquery, form = layui.form, table = layui.table;
var topic = '';
function esc(s){ return String(s==null?'':s).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;'); }
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())+':'+p(d.getSeconds()); }
function reload(){ table.reload('msgTable', { where: topic ? { topic: topic } : {} }); }
form.on('submit(msgSearch)', function(obj){
topic = $.trim(obj.field.topic || '');
reload();
return false;
});
$('#btnReset').on('click', function(){
topic = ''; $('[name=topic]').val('');
reload();
});
table.render({
elem: '#msgTable',
url: '/iot/backend/messagesTable',
page: true,
limit: 20,
limits: [10, 20, 50, 100],
parseData: function (res) {
return {
code: res.code,
msg: res.message || res.msg || '',
count: (res.data && res.data.count) || 0,
data: (res.data && res.data.list) || []
};
},
cols: [[
{ field: 'id', title: 'ID', width: 70, align: 'center' },
{ field: 'client_id', title: 'ClientId', width: 150 },
{ field: 'topic', title: '主题', minWidth: 170 },
{ field: 'qos', title: 'QoS', width: 60, align: 'center' },
{ field: 'source', title: '来源', width: 90, align: 'center', templet: function (d) {
return d.source==='client' ? '客户端' : (d.source==='api' ? '接口' : esc(d.source||'—'));
} },
{ field: 'payload', title: '消息体', minWidth: 240, templet: function (d) {
return '<span style="word-break:break-all;">' + esc(d.payload) + '</span>';
} },
{ field: 'create_at', title: '时间', width: 160, templet: function (d) { return fmtTime(d.create_at); } }
]]
});
});
</script>
+162
View File
@@ -0,0 +1,162 @@
<div class="layui-container">
<div class="admin-title">OTA 固件</div>
<div class="layui-text" style="margin-bottom:10px;color:#999;">
上传固件后,可向指定设备下发升级指令(Topic:/devices/{uuid}/ota)。设备完成/失败会上报进度并写入日志与告警。
</div>
<div style="margin-bottom:10px;">
<button class="layui-btn layui-btn-sm" onclick="uploadFw()">+ 上传固件</button>
</div>
<table id="fwTable" lay-filter="fwTable"></table>
</div>
<!-- 行操作按钮模板 -->
<script type="text/html" id="barOta">
<a class="layui-btn layui-btn-xs" lay-event="push">下发</a>
<a class="layui-btn layui-btn-xs {{d.status==1 ? 'layui-btn-warm' : ''}}" lay-event="toggle">{{d.status==1 ? '停用' : '启用'}}</a>
<a class="layui-btn layui-btn-xs layui-btn-danger" lay-event="del">删除</a>
</script>
<script>
layui.use(['layer', 'jquery', 'table', 'http', 'ywxapp'], function () {
var http = layui.http;
var $ = layui.jquery, layer = layui.layer, table = layui.table;
// 服务端输出 JSON 数组(含 uuid/title/model),前端构造成 uuid 字典供下发勾选
var devices = {$devicesJson|raw};
var deviceMap = {};
(devices || []).forEach(function (d) {
if (d && d.uuid) { deviceMap[d.uuid] = { pname: d.title || '', model: d.model || '' }; }
});
function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;'); }
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('fwTable');
}
// ================= 表格 =================
table.render({
elem: '#fwTable',
url: '/iot/backend/otaTable',
page: true,
limit: 10,
limits: [10, 20, 50, 100],
parseData: function (res) {
return {
code: res.code,
msg: res.message || res.msg || '',
count: (res.data && res.data.count) || 0,
data: (res.data && res.data.list) || []
};
},
cols: [[
{ field: 'id', title: 'ID', width: 70, align: 'center' },
{ field: 'model', title: '型号', width: 140 },
{ field: 'version', title: '版本', width: 100 },
{ field: 'filename', title: '固件文件', minWidth: 180 },
{ field: 'size', title: '大小', width: 110, templet: function (d) { return (Number(d.size) || 0) + ' B'; } },
{ field: 'file_hash', title: 'SHA256', width: 210, templet: function (d) {
return d.file_hash ? '<span title="' + d.file_hash + '" style="cursor:help;font-family:monospace;">' + d.file_hash.substr(0, 16) + '…</span>' : '—';
} },
{ field: 'remark', title: '备注', minWidth: 120 },
{ field: 'status', title: '状态', width: 80, align: 'center', templet: function (d) {
return d.status == 1 ? '<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: 190, align: 'center', toolbar: '#barOta' }
]]
});
table.on('tool(fwTable)', function (obj) {
var d = obj.data;
if (obj.event === 'push') {
pushOta(d);
} else if (obj.event === 'toggle') {
toggleOta(d);
} else if (obj.event === 'del') {
delOta(d);
}
});
// ================= 上传固件 =================
window.uploadFw = function () {
var html = '<form class="layui-form" style="padding:20px;">'
+ '<div class="layui-form-item"><label class="layui-form-label">型号</label>'
+ '<div class="layui-input-inline"><input name="model" class="layui-input" placeholder="如 DevKit-ESP32" required></div></div>'
+ '<div class="layui-form-item"><label class="layui-form-label">版本号</label>'
+ '<div class="layui-input-inline"><input name="version" class="layui-input" placeholder="如 1.2.0" required></div></div>'
+ '<div class="layui-form-item"><label class="layui-form-label">固件文件</label>'
+ '<div class="layui-input-inline"><input name="file" type="file" id="fw-file" accept=".bin,.zip,.hex"></div></div>'
+ '<div class="layui-form-item"><label class="layui-form-label">备注</label>'
+ '<div class="layui-input-inline"><input name="remark" class="layui-input"></div></div>'
+ '</form>';
layer.open({
type: 1, title: '上传固件', area: ['520px', 'auto'], content: html, btn: ['上传', '取消'],
success: function () { layui.form && layui.form.render(); },
yes: function (idx) {
var model = $('[name=model]').val(), version = $('[name=version]').val();
var remark = $('[name=remark]').val();
var file = $('#fw-file')[0].files[0];
if (!model) { layer.msg('请填写设备型号'); return; }
if (!version) { layer.msg('请填写版本号'); return; }
if (!file) { layer.msg('请选择固件文件'); return; }
layui.http.upload('/iot/backend/saveOta', file, { model: model, version: version, remark: remark, status: 1 })
.then(function (res) {
layer.msg(res.message || res.msg || 'ok');
if (res.code === 0) { layer.close(idx); setTimeout(reload, 600); }
})
.catch(function () {
layer.msg('请求失败');
});
}
});
};
// ================= 下发升级 =================
function pushOta(d) {
var opts = '';
for (var uuid in deviceMap) {
if (deviceMap[uuid].model !== d.model) { continue; }
opts += '<label style="display:block;margin:6px 0;cursor:pointer;"><input type="checkbox" name="uuids" value="' + uuid + '" checked> '
+ esc(deviceMap[uuid].pname) + ' <span style="color:#999;">(' + esc(uuid) + ')</span></label>';
}
var html = '<div style="padding:20px;">'
+ '<div style="margin-bottom:10px;color:#666;">将向型号 <b>' + esc(d.model) + '</b> 推送版本 <b>' + esc(d.version) + '</b>,请勾选目标设备:</div>'
+ (opts ? opts : '<div style="color:#999;">该型号下暂无设备</div>')
+ '</div>';
layer.open({
type: 1, title: '下发固件 #' + d.id, area: ['520px', 'auto'], content: html, btn: ['确认下发', '取消'],
yes: function (idx) {
var uuids = [];
$('[name=uuids]:checked').each(function () { uuids.push($(this).val()); });
if (!uuids.length) { layer.msg('请至少选择一台设备'); return; }
http.post('/iot/backend/publishOta', { ota_id: d.id, uuids: uuids }).then(function (res) {
layer.msg(res.message || res.msg || 'ok');
if (res.code === 0) { layer.close(idx); }
});
}
});
}
// ================= 停用 / 启用 =================
function toggleOta(d) {
http.post('/iot/backend/toggleOta', { id: d.id, enabled: d.status == 1 ? 0 : 1 }).then(function (res) {
layer.msg(res.message || res.msg || 'ok');
setTimeout(reload, 600);
});
}
// ================= 删除 =================
function delOta(d) {
layer.confirm('确认删除固件「' + esc(d.filename) + '」(含本地文件)?', function (idx) {
http.post('/iot/backend/deleteOta', { id: d.id }).then(function (res) {
layer.msg(res.message || res.msg || '已删除');
setTimeout(reload, 600);
});
layer.close(idx);
});
}
});
</script>
+46
View File
@@ -0,0 +1,46 @@
<div class="layui-container">
<div class="admin-title">发布消息</div>
<form class="layui-form" id="publishForm">
<div class="layui-form-item">
<label class="layui-form-label">主题</label>
<div class="layui-input-block">
<input type="text" name="topic" required lay-verify="required" placeholder="如 sensor/room1/temp" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">内容</label>
<div class="layui-input-block">
<textarea name="payload" placeholder="消息内容" class="layui-textarea"></textarea>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">QoS</label>
<div class="layui-input-block">
<select name="qos">
<option value="0">0</option>
<option value="1">1</option>
<option value="2">2</option>
</select>
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button class="layui-btn" lay-submit lay-filter="publish">发布</button>
</div>
</div>
</form>
</div>
<script>
layui.use(['form', 'jquery', 'http', 'ywxapp'], function(){
var http = layui.http;
var form = layui.form, $ = layui.jquery;
form.on('submit(publish)', function(data){
http.post('/iot/backend/publish', data.field).then(function(res){
layer.msg(res.message || res.msg || '已提交');
});
return false;
});
});
</script>
+109
View File
@@ -0,0 +1,109 @@
<div class="layui-container">
<div class="admin-title">转发规则(规则引擎 / 桥接到 EMQX)</div>
<div class="layui-text" style="margin-bottom:10px;color:#999;">
匹配"源主题过滤器"的消息,将被转发到外部 MQTT Broker(如 EMQX)。目标主题支持
<code>${topic}</code> 变量(沿用原主题)。规则最多 30s 后在 Broker 生效。
转发依赖 <code>workerman/mqtt</code> 客户端,未安装时自动跳过。
</div>
<div style="margin-bottom:10px;">
<button class="layui-btn layui-btn-sm" onclick="editRule()">+ 新增规则</button>
</div>
<table id="ruleTable" lay-filter="ruleTable"></table>
</div>
<!-- 行操作按钮模板 -->
<script type="text/html" id="barRule">
<a class="layui-btn layui-btn-xs" lay-event="edit">编辑</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, table = layui.table;
function esc(s){ return String(s==null?'':s).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;'); }
function reload(){ table.reload('ruleTable'); }
function ok(res, then){ if(res.code===0){ then(); } else { layer.msg(res.message||'操作失败'); } }
// ================= 表格 =================
table.render({
elem: '#ruleTable',
url: '/iot/backend/ruleTable',
page: true,
limit: 20,
limits: [10, 20, 50, 100],
parseData: function (res) {
return {
code: res.code,
msg: res.message || res.msg || '',
count: (res.data && res.data.count) || 0,
data: (res.data && res.data.list) || []
};
},
cols: [[
{ field: 'name', title: '名称', minWidth: 130 },
{ field: 'source_filter', title: '源主题过滤器', minWidth: 150 },
{ field: 'target_broker', title: '目标Broker', minWidth: 170 },
{ field: 'target_topic', title: '目标主题', minWidth: 140, templet: function (d) {
return d.target_topic ? esc(d.target_topic) : '${topic}';
} },
{ field: 'target_qos', title: 'QoS', width: 60, 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>';
} },
{ title: '操作', width: 150, align: 'center', toolbar: '#barRule' }
]]
});
table.on('tool(ruleTable)', function (obj) {
if (obj.event === 'edit') { editRule(obj.data); }
else if (obj.event === 'del') { delRule(obj.data.id); }
});
// ================= 新增 / 编辑 =================
window.editRule = function(row){
row = row || {};
var sel = function(name, val, opts){
var s = '<select name="'+name+'">';
opts.forEach(function(o){ s += '<option value="'+o[0]+'" '+(String(val)==String(o[0])?'selected':'')+'>'+o[1]+'</option>'; });
return s + '</select>';
};
var html = '<form class="layui-form" style="padding:20px;" lay-filter="ruleForm">'
+ '<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="name" class="layui-input" value="'+esc(row.name)+'"></div></div>'
+ '<div class="layui-form-item"><label class="layui-form-label">源主题过滤器</label><div class="layui-input-inline"><input name="source_filter" class="layui-input" value="'+esc(row.source_filter)+'" placeholder="如 sensor/+/temp"></div></div>'
+ '<div class="layui-form-item"><label class="layui-form-label">目标类型</label><div class="layui-input-inline">'+sel('target_type', row.target_type||'mqtt', [['mqtt','MQTT Broker']])+'</div></div>'
+ '<div class="layui-form-item"><label class="layui-form-label">目标Broker</label><div class="layui-input-inline"><input name="target_broker" class="layui-input" value="'+esc(row.target_broker)+'" placeholder="host:port,如 192.168.1.10:1883"></div></div>'
+ '<div class="layui-form-item"><label class="layui-form-label">目标主题</label><div class="layui-input-inline"><input name="target_topic" class="layui-input" value="'+esc(row.target_topic)+'" placeholder="${topic} 表示沿用原主题"></div></div>'
+ '<div class="layui-form-item"><label class="layui-form-label">目标QoS</label><div class="layui-input-inline">'+sel('target_qos', (row.target_qos===undefined?0:row.target_qos), [[0,'0'],[1,'1'],[2,'2']])+'</div></div>'
+ '<div class="layui-form-item"><label class="layui-form-label">ClientID</label><div class="layui-input-inline"><input name="target_clientid" class="layui-input" value="'+esc(row.target_clientid)+'" placeholder="可选"></div></div>'
+ '<div class="layui-form-item"><label class="layui-form-label">用户名</label><div class="layui-input-inline"><input name="target_username" class="layui-input" value="'+esc(row.target_username)+'" placeholder="可选"></div></div>'
+ '<div class="layui-form-item"><label class="layui-form-label">密码</label><div class="layui-input-inline"><input name="target_password" type="password" class="layui-input" value="'+esc(row.target_password)+'" placeholder="可选"></div></div>'
+ '<div class="layui-form-item"><label class="layui-form-label">状态</label><div class="layui-input-inline">'+sel('enabled', (row.enabled===undefined?1:row.enabled), [[1,'启用'],[0,'停用']])+'</div></div>'
+ '<div class="layui-form-item"><label class="layui-form-label">备注</label><div class="layui-input-inline"><input name="remark" class="layui-input" value="'+esc(row.remark)+'"></div></div>'
+ '</form>';
layer.open({type:1, title:(row.id?'编辑':'新增')+'转发规则', area:['560px','auto'], content:html, btn:['保存','取消'],
success:function(){ layui.form.render(); },
yes:function(idx){
var data = {};
$('[lay-filter=ruleForm]').serializeArray().forEach(function(i){ data[i.name]=i.value; });
http.post('/iot/backend/saveRule', data).then(function(res){
layer.msg(res.message||res.msg||'ok');
ok(res, function(){ layer.close(idx); setTimeout(reload, 600); });
});
}
});
};
// ================= 删除 =================
window.delRule = function(id){
layer.confirm('确认删除该规则?', function(idx){
http.post('/iot/backend/deleteRule', {id:id}).then(function(res){
layer.msg(res.message||'已删除');
setTimeout(reload, 600);
});
layer.close(idx);
});
};
});
</script>
+202
View File
@@ -0,0 +1,202 @@
<div class="layui-container">
<div class="admin-title">场景联动</div>
<div class="layui-text" style="margin-bottom:10px;color:#999;">
智慧生活自动化:设备状态(如湿度&gt;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,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;'); }
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>
+41
View File
@@ -0,0 +1,41 @@
<div class="layui-card-body">
<div class="admin-title">服务设置</div>
<form class="layui-form" id="settingForm">
{foreach $def as $item}
<div class="layui-form-item">
<label class="layui-form-label">{$item.title}</label>
<div class="layui-input-inline">
{if isset($item.options) && $item.options}
<select name="{$item.name}">
{foreach $item.options as $k=>$v}
<option value="{$k}" {if (isset($saved[$item['name']]) && $saved[$item['name']]==$k)}selected{/if}>{$v}</option>
{/foreach}
</select>
{else}
<input type="text" name="{$item.name}" value="{$saved[$item['name']] ?? $item.value}" class="layui-input">
{/if}
</div>
{if isset($item.tip) && $item.tip}<div class="layui-form-mid layui-word-aux">{$item.tip}</div>{/if}
</div>
{/foreach}
<div class="layui-form-item">
<div class="layui-input-block">
<button class="layui-btn" lay-submit lay-filter="save">保存</button>
</div>
</div>
</form>
</div>
<script>
layui.use(['form', 'jquery', 'http', 'ywxapp'], function(){
var http = layui.http;
var form = layui.form, $ = layui.jquery;
form.on('submit(save)', function(data){
http.post('/iot/backend/setting', data.field).then(function(res){
layer.msg(res.message || '已保存');
});
return false;
});
});
</script>