安全(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(市场 / 开发者中心 / 审核 / 分润)
163 lines
8.7 KiB
HTML
163 lines
8.7 KiB
HTML
<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>
|