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>
|
||||
@@ -0,0 +1,180 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>YwxApp IoT 物联网平台 · 能力总览</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0b1220; --bg2:#101a2e; --card:#121e33; --line:#1f3050;
|
||||
--txt:#e7eefc; --sub:#8fa3c8; --acc:#3b82f6; --acc2:#22d3ee;
|
||||
--ok:#34d399; --warn:#fbbf24;
|
||||
}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:var(--bg);color:var(--txt);font-family:"PingFang SC","Microsoft YaHei",system-ui,sans-serif;line-height:1.7}
|
||||
.wrap{max-width:1080px;margin:0 auto;padding:0 24px}
|
||||
code{font-family:Consolas,Menlo,monospace;font-size:.92em;background:#0a1526;border:1px solid var(--line);border-radius:4px;padding:1px 6px;color:var(--acc2)}
|
||||
|
||||
/* ---------- hero ---------- */
|
||||
.hero{position:relative;padding:80px 0 60px;background:
|
||||
radial-gradient(ellipse 60% 50% at 70% 10%,rgba(59,130,246,.18),transparent),
|
||||
radial-gradient(ellipse 40% 40% at 20% 80%,rgba(34,211,238,.10),transparent),
|
||||
linear-gradient(180deg,#0d1526,var(--bg));border-bottom:1px solid var(--line)}
|
||||
.badge{display:inline-block;font-size:12px;letter-spacing:2px;color:var(--acc2);border:1px solid var(--line);border-radius:99px;padding:3px 14px;margin-bottom:18px;background:rgba(34,211,238,.06)}
|
||||
.hero h1{font-size:40px;font-weight:700;letter-spacing:1px}
|
||||
.hero h1 em{font-style:normal;background:linear-gradient(90deg,var(--acc),var(--acc2));-webkit-background-clip:text;background-clip:text;color:transparent}
|
||||
.hero p{color:var(--sub);font-size:16px;max-width:640px;margin:14px 0 26px}
|
||||
.live{display:flex;flex-wrap:wrap;gap:14px}
|
||||
.live .item{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:10px 18px;min-width:130px}
|
||||
.live .k{font-size:12px;color:var(--sub)}
|
||||
.live .v{font-size:22px;font-weight:700;color:var(--acc2);font-family:Consolas,monospace}
|
||||
.live .v.g{color:var(--ok)}
|
||||
.refresh{color:var(--sub);font-size:12px;margin-left:auto;align-self:center}
|
||||
|
||||
/* ---------- section ---------- */
|
||||
section{padding:56px 0;border-bottom:1px solid var(--line)}
|
||||
h2{font-size:24px;margin-bottom:6px}
|
||||
h2 span{color:var(--acc);margin-right:8px;font-family:Consolas,monospace}
|
||||
.sub{color:var(--sub);font-size:14px;margin-bottom:28px}
|
||||
|
||||
/* 能力卡片 */
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(310px,1fr));gap:16px}
|
||||
.cap{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:22px;transition:border-color .2s}
|
||||
.cap:hover{border-color:var(--acc)}
|
||||
.cap .ico{font-size:24px;width:46px;height:46px;display:flex;align-items:center;justify-content:center;border-radius:10px;background:linear-gradient(135deg,rgba(59,130,246,.2),rgba(34,211,238,.12));margin-bottom:14px}
|
||||
.cap h3{font-size:16px;margin-bottom:8px}
|
||||
.cap p{font-size:13px;color:var(--sub)}
|
||||
.cap p b{color:var(--txt);font-weight:600}
|
||||
|
||||
/* 优势行 */
|
||||
.adv{display:grid;grid-template-columns:repeat(auto-fill,minmax(480px,1fr));gap:14px}
|
||||
.adv .row{display:flex;gap:14px;background:var(--card);border:1px solid var(--line);border-radius:10px;padding:16px 18px}
|
||||
.adv .n{flex:none;width:26px;height:26px;border-radius:50%;background:linear-gradient(135deg,var(--acc),var(--acc2));color:#fff;font-size:13px;font-weight:700;display:flex;align-items:center;justify-content:center;margin-top:3px}
|
||||
.adv h4{font-size:14px;margin-bottom:4px}
|
||||
.adv p{font-size:12.5px;color:var(--sub)}
|
||||
|
||||
/* 链路图 */
|
||||
.flow{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:26px;overflow-x:auto}
|
||||
.flow .chain{display:flex;align-items:stretch;gap:0;min-width:720px}
|
||||
.node{flex:1;border:1px solid var(--line);border-radius:10px;padding:14px 10px;text-align:center;background:var(--bg2)}
|
||||
.node .t{font-size:14px;font-weight:600}
|
||||
.node .d{font-size:11px;color:var(--sub);margin-top:4px}
|
||||
.arrow{flex:none;width:56px;display:flex;align-items:center;justify-content:center;color:var(--acc);font-size:18px}
|
||||
.flow .note{margin-top:16px;font-size:12.5px;color:var(--sub)}
|
||||
|
||||
/* 快速开始 */
|
||||
.steps{counter-reset:s}
|
||||
.step{position:relative;padding:0 0 22px 44px}
|
||||
.step::before{counter-increment:s;content:counter(s);position:absolute;left:0;top:0;width:30px;height:30px;border-radius:50%;background:linear-gradient(135deg,var(--acc),var(--acc2));color:#fff;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:14px}
|
||||
.step h4{font-size:15px;margin-bottom:4px}
|
||||
.step p{font-size:13px;color:var(--sub)}
|
||||
|
||||
footer{padding:34px 0;color:var(--sub);font-size:12px;text-align:center}
|
||||
footer b{color:var(--txt)}
|
||||
@media(max-width:720px){.hero h1{font-size:30px}.adv{grid-template-columns:1fr}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ============ HERO ============ -->
|
||||
<div class="hero"><div class="wrap">
|
||||
<div class="badge">YwxApp Addon · IoT v1.0</div>
|
||||
<h1>自研 <em>MQTT Broker</em> 物联网平台插件</h1>
|
||||
<p>设备接入 · 实时监测 · 告警通知 · 场景联动 · 固件 OTA · 共享协作 —— 一套 PHP 常驻进程全搞定,无需 EMQX / Mosquitto 外部中间件。</p>
|
||||
<div class="live">
|
||||
<div class="item"><div class="k">Broker 在线连接</div><div class="v g">{$stats.online}</div></div>
|
||||
<div class="item"><div class="k">接入设备</div><div class="v">{$stats.devices}</div></div>
|
||||
<div class="item"><div class="k">累计消息</div><div class="v">{$stats.recv}</div></div>
|
||||
<div class="item"><div class="k">时序数据点</div><div class="v">{$stats.points}</div></div>
|
||||
<div class="item"><div class="k">运行时长</div><div class="v">{$stats.uptime}</div></div>
|
||||
<div class="refresh">快照刷新于 {$now}</div>
|
||||
</div>
|
||||
</div></div>
|
||||
|
||||
<!-- ============ 核心能力 ============ -->
|
||||
<section><div class="wrap">
|
||||
<h2><span>01</span>核心能力</h2>
|
||||
<p class="sub">八大能力域覆盖设备全生命周期,从出厂激活到固件升级。</p>
|
||||
<div class="grid">
|
||||
<div class="cap"><div class="ico">🔌</div><h3>自研 MQTT Broker</h3><p>纯 PHP 常驻进程实现 MQTT 3.1.1 协议服务端,QoS0/1/2、保留消息、遗嘱、会话恢复全支持。当前累计转发 <b>{$stats.recv}</b> 条消息,保留消息 <b>{$stats.retained}</b> 条。</p></div>
|
||||
<div class="cap"><div class="ico">📱</div><h3>设备接入三步走</h3><p>出厂预置激活令牌 → 首次联网一次性换领一机一密凭证 → App 扫码 SN + 配对码绑定认领。激活即换发凭证,杜绝裸设备上线。</p></div>
|
||||
<div class="cap"><div class="ico">🌡️</div><h3>多路传感器监测</h3><p>每设备 <b>3 路总线 × 3 探头</b>(DS18B20 OneWire),位置级温度/温差上报告警。当前已落库 <b>{$stats.points}</b> 个时序点。</p></div>
|
||||
<div class="cap"><div class="ico">📈</div><h3>历史曲线双接口</h3><p>原始<b>稀疏点</b>(每条带有效期,App 前向填充)+ <b>分钟级聚合</b>(时间加权平均,step/window 可控:最近 30/60 分钟每分钟一点)。</p></div>
|
||||
<div class="cap"><div class="ico">🔔</div><h3>云端复核告警</h3><p>设备端位图初判 + 云端 <code>capture()</code> 复核落库,防误报防刷屏(窗口抑制)。未读角标 / 已读回执 / App 推送一路打通。</p></div>
|
||||
<div class="cap"><div class="ico">⚡</div><h3>场景联动 + 分组</h3><p>温度越限 → 下发命令 → 设备执行,全链路 < 秒级。设备分组批量控制,当前 <b>{$stats.scenes}</b> 个场景在跑。</p></div>
|
||||
<div class="cap"><div class="ico">🤝</div><h3>共享权限模型</h3><p>主人 / 可控制(perm=2)/ 只读(perm=1)三级权限,HTTP API 与 Broker ACL <b>双层校验</b>。当前有效共享 <b>{$stats.shares}</b> 条。</p></div>
|
||||
<div class="cap"><div class="ico">🚀</div><h3>固件 OTA 远程升级</h3><p>版本管理 + 分批推送 + 进度跟踪,断电续传由固件侧分片请求保障。无需拆机刷写。</p></div>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<!-- ============ 架构优势 ============ -->
|
||||
<section><div class="wrap">
|
||||
<h2><span>02</span>架构优势</h2>
|
||||
<p class="sub">不是"又一个 MQTT 接入",每一个设计点都对应一次真实踩坑的教训。</p>
|
||||
<div class="adv">
|
||||
<div class="row"><div class="n">1</div><div><h4>零外部中间件依赖</h4><p>Broker 内嵌于插件(<code>php think iot start -d</code>),单机即可跑通,Windows 调试 / Linux 生产同构,运维成本≈0。</p></div></div>
|
||||
<div class="row"><div class="n">2</div><div><h4>稀疏点存储,带宽与体积双赢</h4><p>温变才上报(10s~120s 一条),每条带 <code>iv</code> 有效期,不做服务端插值放大。500 点曲线 ≈ 一张缩略图的体积。</p></div></div>
|
||||
<div class="row"><div class="n">3</div><div><h4>配置版本对齐机制</h4><p>服务端 <code>cfg_ver</code> ↔ 设备 <code>sync_ver</code> 回执对齐,retained 下发 + 断线重连自动补推,配置永不漂移、永不丢。</p></div></div>
|
||||
<div class="row"><div class="n">4</div><div><h4>离线可用的配置中心</h4><p>设备配置真源在云端(后台编辑 → retained 下发 → App 共用同一份),设备离线也能读能存,上线即生效。</p></div></div>
|
||||
<div class="row"><div class="n">5</div><div><h4>安全三道闸</h4><p>设备侧 uuid/secret 一机一密 · 会员侧 mqttTicket 短时票据 · Broker ACL 按身份收敛订阅/发布范围,越权即断连。</p></div></div>
|
||||
<div class="row"><div class="n">6</div><div><h4>API 按域分组,一眼可辨</h4><p><code>client/</code> 设备管理 · <code>monitor/</code> 监测 · <code>scene/</code> 场景 · <code>ota/</code> 升级 · <code>mqtt/</code> Broker,旧路径兼容别名平滑迁移。</p></div></div>
|
||||
</div>
|
||||
|
||||
<h2 style="margin-top:36px"><span>03</span>数据链路</h2>
|
||||
<p class="sub">固件 → Broker → 落库 → App,全程秒级。</p>
|
||||
<div class="flow">
|
||||
<div class="chain">
|
||||
<div class="node"><div class="t">固件 (ESP32-C3)</div><div class="d">3 路 OneWire 总线<br>温变触发上报</div></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="node"><div class="t">MQTT Broker</div><div class="d">鉴权 / ACL / QoS1<br>retained 配置下发</div></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="node"><div class="t">落库 + 告警复核</div><div class="d">iot_sensor_point<br>capture() 防误报</div></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="node"><div class="t">场景引擎</div><div class="d">规则匹配 → 命令<br>outbox 可靠投递</div></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="node"><div class="t">App</div><div class="d">实时推送 / 曲线<br>分钟级聚合查询</div></div>
|
||||
</div>
|
||||
<div class="note">下行(配置/命令/OTA)统一走 outbox 队列可靠投递,设备 ack 回执闭环;App 侧「读写一律 HTTP API,MQTT 连接只订不发」。</div>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<!-- ============ 开放能力 ============ -->
|
||||
<section><div class="wrap">
|
||||
<h2><span>04</span>开放能力</h2>
|
||||
<p class="sub">App 端 HTTP 接口按功能域划分、全部经会员鉴权;接口规模由路由表实时统计,能力扩张页面自动更新。</p>
|
||||
<div class="grid">
|
||||
{foreach $apiGroups as $group}
|
||||
<div class="cap"><div class="ico">{$group.ico}</div><h3>{$group.label}</h3><p><b>{$group.count}</b> 个接口 · {$group.blurb}</p></div>
|
||||
{/foreach}
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<!-- ============ 消息通道设计 ============ -->
|
||||
<section><div class="wrap">
|
||||
<h2><span>05</span>消息通道设计</h2>
|
||||
<p class="sub">命名空间隔离 + 身份裁决,连接层即安全边界——不依赖主题名保密。</p>
|
||||
<div class="grid">
|
||||
<div class="cap"><div class="ico">🔒</div><h3>设备私有命名空间</h3><p>每台设备独占自己的主题空间,发布 / 订阅权限由 Broker ACL 按归属实时裁决,越权订阅直接拒绝。</p></div>
|
||||
<div class="cap"><div class="ico">📮</div><h3>会员通知通道</h3><p>告警 / 共享 / 系统通知按账号隔离实时推送;App 连接「只订不发」,读写一律走鉴权 HTTP API。</p></div>
|
||||
<div class="cap"><div class="ico">💾</div><h3>retained 设备影子</h3><p>状态与配置以保留消息存储于 Broker,设备离线可查最后状态,上线即自动对齐配置版本。</p></div>
|
||||
<div class="cap"><div class="ico">✅</div><h3>回执闭环</h3><p>下行命令 / 配置经队列可靠投递,设备 ack 回执对齐版本号,未达即重发自愈。</p></div>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<!-- ============ 快速开始 ============ -->
|
||||
<section><div class="wrap">
|
||||
<h2><span>06</span>快速开始</h2>
|
||||
<p class="sub">从零到设备上线,三步。</p>
|
||||
<div class="steps">
|
||||
<div class="step"><h4>启动 Broker</h4><p>命令行执行 <code>php think iot start -d</code>,常驻进程开始监听 MQTT 端口并每 2 秒写入运行快照。</p></div>
|
||||
<div class="step"><h4>登记设备</h4><p>后台「设备管理」预登记 SN 与激活令牌 → 设备首次联网完成入云激活,一次性领取设备凭证。</p></div>
|
||||
<div class="step"><h4>App 绑定监测</h4><p>App 扫码 SN + 输入配对码绑定 → 配置传感器位置阈值 → 曲线 / 告警 / 场景联动全部就绪。</p></div>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<footer><div class="wrap">
|
||||
<b>YwxApp IoT Addon</b> · 自研 MQTT Broker · 当前在线 {$stats.online} 台设备 / {$stats.alerts} 条告警记录 · 数据快照 {$now}
|
||||
</div></footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user