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:
1 parent
1d49e6f5ee
commit
18101cf11a
636 files changed
+58517
-9519
No files matched your search
@@ -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,'&').replace(/"/g,'"').replace(/</g,'<'); }
|
||||
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>
|
||||
@@ -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,'&').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())+':'+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>
|
||||
@@ -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,'&').replace(/"/g,'"').replace(/</g,'<'); }
|
||||
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,'&').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())+':'+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>
|
||||
@@ -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,'&').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('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> | 配置版本:<b>v' + cfg.cfg_ver + '</b> | 同步状态:' + 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>
|
||||
@@ -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,'&').replace(/"/g,'"').replace(/</g,'<'); }
|
||||
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>
|
||||
@@ -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,'&').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())+':'+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,'&').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())+':'+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>
|
||||
@@ -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, '&').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('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>
|
||||
@@ -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>
|
||||
@@ -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,'&').replace(/"/g,'"').replace(/</g,'<'); }
|
||||
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>
|
||||
@@ -0,0 +1,202 @@
|
||||
<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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user