安全(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(市场 / 开发者中心 / 审核 / 分润)
336 lines
16 KiB
JavaScript
336 lines
16 KiB
JavaScript
layui.define(['http', 'setter', 'auth', 'upload', 'form', 'layer', 'table'], function (exports) {
|
||
var table = layui.table, form = layui.form, layer = layui.layer, $ = layui.$, upload = layui.upload, http = layui.http
|
||
, auth = layui.auth;
|
||
var _savedPerms = (layui.data('backend').permission) || [];
|
||
auth.init(_savedPerms);
|
||
auth.setController('addon');
|
||
var ADDON_DEV = window.ADDON_DEV || 0;
|
||
|
||
// 清空后台侧栏菜单的本地缓存(Layui data 存储于 setter.appName 下,键为 'menus')。
|
||
// 插件卸载/安装/启停会改变 admin_power 菜单,必须清掉缓存让侧栏重新向后端拉取,
|
||
// 否则侧栏会一直显示(或一直缺)已变更插件的菜单。
|
||
function clearMenuCache() {
|
||
var appName = (layui.setter && layui.setter.appName) || 'backend';
|
||
layui.data(appName, { key: 'menus', remove: true });
|
||
// 兼容旧键名(历史版本曾写入 'menu')
|
||
layui.data(appName, { key: 'menu', remove: true });
|
||
}
|
||
// 安装/升级失败的可读化:后端 AddonService::validateDependencies() 收集式校验后,
|
||
// 会在 data.problems 里带上完整清单(缺哪些依赖、该装还是该升),这里逐条展示并给
|
||
// 「去应用市场安装」入口,避免只弹出一长串难以扫读的文案。
|
||
function showDependencyProblems(res) {
|
||
var data = res && res.data;
|
||
var list = (data && data.problems) || [];
|
||
if (!list.length) { return false; }
|
||
var badgeText = { addon: '插件', extension: 'PHP 扩展', framework: '框架' };
|
||
var rows = list.map(function (it) {
|
||
var badge = badgeText[it.type] || '依赖';
|
||
return '<li style="padding:6px 0;border-bottom:1px dashed #eee;">'
|
||
+ '<span class="layui-badge layui-bg-cyan" style="margin-right:6px;">' + badge + '</span>'
|
||
+ $('<div>').text(it.text).html()
|
||
+ '</li>';
|
||
}).join('');
|
||
var missing = (data && data.missing_addons) || [];
|
||
var extra = missing.length
|
||
? '<div style="margin-top:12px;"><a class="layui-btn layui-btn-normal layui-btn-xs" href="market" target="_blank">'
|
||
+ '去应用市场安装缺失插件(' + $('<span>').text(missing.join('、')).html() + ')</a></div>'
|
||
: '';
|
||
layer.open({
|
||
type: 1,
|
||
title: '依赖检查未通过(' + list.length + ' 项)',
|
||
area: ['620px', 'auto'],
|
||
content: '<div style="padding:16px 18px;font-size:13px;">'
|
||
+ '<ul style="list-style:none;margin:0;padding:0;">' + rows + '</ul>' + extra + '</div>',
|
||
btn: ['知道了']
|
||
});
|
||
return true;
|
||
}
|
||
|
||
// ===== 渲染核心表格 =====
|
||
var { access_token, refresh_token } = layui.setter.getTokens();
|
||
table.render({
|
||
elem: '#dataTable'
|
||
, url: "index" // 此处应填写你真实的后端接口地址
|
||
, cols: [[
|
||
{ field: 'title', title: '插件名称', minWidth: 180 },
|
||
{ field: 'name', title: '标识', width: 120 },
|
||
{ field: 'author', title: '开发者', width: 120 }
|
||
, { field: 'version', title: '版本号', width: 170, align: 'center', templet: '#versionTpl' }
|
||
, { field: 'description', title: '功能描述', minWidth: 250 }
|
||
, { field: 'status', title: '运行状态', width: 110, align: 'center', templet: '#switchTpl' } // 绑定开关模板
|
||
, { fixed: 'right', title: '操作', toolbar: '#operateTpl', width: 300, align: 'right' } // 绑定操作栏模板
|
||
]]
|
||
, page: true // 开启分页
|
||
, limit: 10
|
||
, limits: [10, 20, 50]
|
||
, skin: 'line' // 设定表格风格为行边框风格
|
||
, response: {
|
||
statusName: 'code' // 状态码字段
|
||
, statusCode: 0 // 成功状态码
|
||
, msgName: 'message' // 提示信息字段(后端返回 message)
|
||
, countName: 'count' // 数据总数字段
|
||
, dataName: 'data' // 数据列表字段
|
||
}
|
||
, done: function (res, curr, count) {
|
||
var view = $('#dataTable').next('.layui-table-view');
|
||
auth.filterDom(view);
|
||
auth.filterDom('.layui-btn[data-perm]');
|
||
// 开发模式关闭时隐藏操作列的「设计」按钮
|
||
// if (!ADDON_DEV) { $('.dev-design').remove(); }
|
||
}
|
||
});
|
||
|
||
// ===== 事件绑定(提前到这里:即便下方 upload/form 初始化出错,行内按钮依然可用) =====
|
||
table.on('toolbar(dataTable)', function (elem) {
|
||
switch (elem.event) {
|
||
case 'dataCreate':
|
||
active.dataCreate({ pid: 0, type: 1 });
|
||
break;
|
||
case 'dataRecybin':
|
||
active.dataRecybin();
|
||
break;
|
||
}
|
||
});
|
||
|
||
table.on('tool(dataTable)', function (elem) {
|
||
var data = elem.data;
|
||
// 插件标识:优先用 name,缺失时回退到 id(后端 index 两种都返回)
|
||
var addonName = data.name || data.id || '';
|
||
switch (elem.event) {
|
||
case 'update':
|
||
active.dataEdit(data);
|
||
break;
|
||
case 'config':
|
||
if (!addonName) { layer.msg('缺少插件标识', { icon: 2 }); break; }
|
||
location.href = 'setting?addon=' + encodeURIComponent(addonName);
|
||
break;
|
||
case 'upgrade':
|
||
if (!addonName) { layer.msg('缺少插件标识', { icon: 2 }); break; }
|
||
active.upgrade(addonName, data.latest_version || '');
|
||
break;
|
||
case 'uninstall':
|
||
if (!addonName) { layer.msg('缺少插件标识', { icon: 2 }); break; }
|
||
active.uninstall(addonName);
|
||
break;
|
||
case 'more':
|
||
var that = this;
|
||
var dropdown = layui.dropdown;
|
||
// 水平对齐方式
|
||
dropdown.render({
|
||
elem: that,
|
||
align: 'center', // align 已配置在元素 `lay-options` 属性上
|
||
show: true,accordion : true,
|
||
data: [{
|
||
title: '设计',
|
||
id: 'design'
|
||
}, {
|
||
title: '打包',
|
||
id: 'pack'
|
||
}, {
|
||
title: '发布',
|
||
id: 'submitOfficial'
|
||
}],
|
||
click: function (data, othis) {
|
||
switch (data.id) {
|
||
case 'pack':
|
||
// 触发后端 addon::pack 下载该插件的 zip 包(addon/temp/<name>-<version>.zip)
|
||
if (!addonName) { layer.msg('缺少插件标识', { icon: 2 }); break; }
|
||
window.location.href = 'pack?name=' + encodeURIComponent(addonName);
|
||
break;
|
||
case 'design':
|
||
if (!addonName) { layer.msg('缺少插件标识', { icon: 2 }); break; }
|
||
location.href = 'design?addon=' + encodeURIComponent(addonName);
|
||
break;
|
||
case 'submitOfficial':
|
||
// 发布到官方市场:打包本机已装插件并 POST 到官方提交接口,进入待审核
|
||
if (!addonName) { layer.msg('缺少插件标识', { icon: 2 }); break; }
|
||
layer.confirm('确定将「' + (data.title || addonName) + '」发布到官方市场吗?\n提交后需官方审核通过才公开上架。', function (index) {
|
||
layer.close(index);
|
||
var loadIdx = layer.load(1, { shade: [0.3, '#fff'] });
|
||
http.post('submitOfficial', { name: addonName })
|
||
.then(function (res) {
|
||
layer.close(loadIdx);
|
||
if (res.code === 0) {
|
||
layer.msg(res.message || '已提交,等待官方审核', { icon: 1 });
|
||
} else {
|
||
layer.msg(res.message || res.msg || '提交失败', { icon: 2 });
|
||
}
|
||
})
|
||
.catch(function () {
|
||
layer.close(loadIdx);
|
||
layer.msg('请求失败', { icon: 2 });
|
||
});
|
||
});
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
},
|
||
});
|
||
break;
|
||
case 'default':
|
||
console.warn('未处理的操作事件:', elem.event, data);
|
||
}
|
||
});
|
||
|
||
// 顶部功能按钮 + 行内操作共用
|
||
var active = {
|
||
// 一键升级到市场最新版(version 为空时由服务端取最新版)
|
||
upgrade: function (name, version) {
|
||
var loadIdx = layer.load();
|
||
// 升级含下载+备份+覆盖,放宽超时到 10 分钟,避免半途被中断
|
||
http.post('upgrade', { name: name, version: version }, { timeout: 600000 })
|
||
.then(function (res) {
|
||
layer.close(loadIdx);
|
||
if (res.code === 0) {
|
||
layer.msg(res.message || '升级成功', { icon: 1 });
|
||
clearMenuCache(); // 插件菜单可能变更
|
||
table.reload('dataTable');
|
||
} else if (!showDependencyProblems(res)) {
|
||
layer.msg(res.message || '升级失败', { icon: 2 });
|
||
}
|
||
})
|
||
.catch(function () {
|
||
layer.close(loadIdx);
|
||
layer.msg('请求失败', { icon: 2 });
|
||
});
|
||
},
|
||
uninstall: function (name) {
|
||
// 卸载选项:是否备份数据 / 是否删除数据表(对应 AddonService::uninstall() 的 options)
|
||
layer.open({
|
||
type: 1,
|
||
title: '卸载插件:' + name,
|
||
area: ['460px', 'auto'],
|
||
content: '<div style="padding:16px 18px;">'
|
||
+ '<label style="display:block;margin-bottom:10px;">'
|
||
+ '<input type="checkbox" id="unin_backup" checked> 卸载前备份数据(SQL 落到 runtime/addon/backup/)'
|
||
+ '</label>'
|
||
+ '<label style="display:block;">'
|
||
+ '<input type="checkbox" id="unin_delete" checked> 同时 DROP 插件数据表(不勾选则保留数据)'
|
||
+ '</label>'
|
||
+ '<div class="layui-word-aux" style="margin-top:10px;">'
|
||
+ '提示:删除数据表后数据不可恢复,建议保持「备份数据」勾选。</div>'
|
||
+ '</div>',
|
||
btn: ['下一步', '取消'],
|
||
yes: function (index) {
|
||
var opts = {
|
||
backup_data: document.getElementById('unin_backup').checked ? 1 : 0,
|
||
delete_data: document.getElementById('unin_delete').checked ? 1 : 0
|
||
};
|
||
layer.close(index);
|
||
layer.prompt({
|
||
formType: 1
|
||
, title: '敏感操作,请验证口令'
|
||
}, function (value, index2) {
|
||
layer.close(index2);
|
||
http.delete('uninstall', {
|
||
name: name,
|
||
backup_data: opts.backup_data,
|
||
delete_data: opts.delete_data
|
||
})
|
||
.then((res) => {
|
||
if (res.code === 0) {
|
||
clearMenuCache(); // 插件菜单已删除,清掉侧栏菜单缓存
|
||
location.reload(); // 重新加载整页,侧栏立即刷新
|
||
} else {
|
||
layer.msg(res.msg || '操作失败');
|
||
}
|
||
});
|
||
}
|
||
}
|
||
});
|
||
},
|
||
batchDel: function () { // 批量卸载
|
||
var checkStatus = table.checkStatus('dataTable');
|
||
var data = checkStatus.data;
|
||
if (data.length === 0) {
|
||
layer.msg('请至少选中一项进行卸载', { icon: 0 });
|
||
return;
|
||
}
|
||
layer.confirm('确定要批量卸载选中的 ' + data.length + ' 个插件吗?', function (index) {
|
||
layer.msg('正在批量卸载...', { icon: 1 });
|
||
table.reload('dataTable');
|
||
layer.close(index);
|
||
});
|
||
},
|
||
refresh: function () { // 刷新表格
|
||
table.reload('dataTable');
|
||
layer.msg('已刷新列表', { icon: 1 });
|
||
}
|
||
};
|
||
|
||
$('.layui-btn').on('click', function () {
|
||
var type = $(this).data('type');
|
||
active[type] && active[type].call(this);
|
||
});
|
||
|
||
console.log(layui.setter.getTokens());
|
||
var inst = upload.render({
|
||
elem: '#buttonInstall',
|
||
field: 'file',
|
||
url: "upload", // 上传接口地址
|
||
accept: 'file', // 只允许上传文件
|
||
// 注意:不要设置 acceptMime。该属性会让浏览器文件选择框按 MIME 过滤,
|
||
// 而 zip 在本机常被识别为 application/x-zip-compressed / application/octet-stream,
|
||
// 导致选择框中插件包被置灰、无法选中。仅用 exts 在选完后校验后缀即可。
|
||
exts: 'zip', // 仅允许 zip 插件包(后端 AddonService 只支持 zip)
|
||
headers: { 'Authorization': `Bearer ${access_token}` }, // 鉴权
|
||
|
||
done: function (res) {
|
||
// 上传成功的回调
|
||
if (res.code == 0) {
|
||
layer.msg('插件安装成功!', { icon: 1 });
|
||
clearMenuCache(); // 插件菜单已变化,清掉侧栏菜单缓存
|
||
location.reload(); // 重新加载整页,侧栏立即刷新
|
||
} else if (!showDependencyProblems(res)) {
|
||
layer.msg('安装失败: ' + (res.message || '未知错误'), { icon: 2 });
|
||
}
|
||
},
|
||
error: function () {
|
||
// 上传失败的回调
|
||
// 当上传失败时,可在此处生成「重新上传」按钮,并执行该方法重新触发上传提交
|
||
/*
|
||
$('#btn').on('click', function(){
|
||
inst.upload();
|
||
})
|
||
*/
|
||
}
|
||
});
|
||
// ===== 监听顶部搜索表单提交 =====
|
||
form.on('submit(searchBtn)', function (data) {
|
||
var field = data.field; // 获取搜索表单的值
|
||
// 重载表格,将搜索条件传给后端
|
||
table.reload('dataTable', {
|
||
where: field,
|
||
page: { curr: 1 } // 搜索后回到第一页
|
||
});
|
||
return false; // 阻止表单默认跳转
|
||
});
|
||
|
||
// ===== 监听状态开关(启用/停用)的切换 =====
|
||
form.on('switch(statusSwitch)', function (obj) {
|
||
var name = this.value; // value 绑定的是插件名 d.id
|
||
var status = obj.elem.checked ? 1 : 0; // 根据开关状态赋值
|
||
var action = status ? 'enable' : 'disable';
|
||
var that = obj.othis;
|
||
http.post('toggle', { name: name, action: action })
|
||
.then(function (res) {
|
||
if (res.code === 0) {
|
||
layer.msg(status ? '已启用' : '已停用', { icon: 1 });
|
||
clearMenuCache(); // 菜单可见性已变化,清掉侧栏菜单缓存(下次导航即刷新)
|
||
} else {
|
||
layer.msg(res.message || '操作失败', { icon: 2 });
|
||
that.find('input').prop('checked', !status);
|
||
form.render();
|
||
}
|
||
})
|
||
.catch(function () {
|
||
layer.msg('请求失败', { icon: 2 });
|
||
that.find('input').prop('checked', !status);
|
||
form.render();
|
||
});
|
||
});
|
||
|
||
exports('addon', {}); // 输出模块接口
|
||
});
|