Files
YwxAppThink/public/static/backend/modules/datatable.js
T
yangwx 18101cf11a 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(市场 / 开发者中心 / 审核 / 分润)
2026-10-22 00:53:39 +08:00

409 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 数据表管理:列表 / 可视化建表 / 查看结构 / 优化 / 删表
*
* 与 database.js 同构:URL 只写动作名(当前页面目录即 /admin/datatable/),
* 权限按钮用完整权限名,渲染后由 auth.filterDom() 移除无权限按钮。
*/
layui.define(['table', 'layer', 'form', 'element', 'auth', 'http', 'jquery'], function (exports) {
var table = layui.table,
layer = layui.layer,
form = layui.form,
element = layui.element,
auth = layui.auth,
http = layui.http,
$ = layui.jquery;
var FUNC = {
index: 'index',
structure: 'structure',
preview: 'preview',
create: 'create',
drop: 'drop',
optimize: 'optimize',
export: 'export'
};
// 与后端 Datatable::TYPES 保持一致
var TYPES = [
'tinyint', 'smallint', 'mediumint', 'int', 'bigint',
'decimal', 'float', 'double',
'char', 'varchar', 'text', 'mediumtext', 'longtext',
'date', 'datetime', 'timestamp', 'time', 'year',
'json', 'blob', 'longblob'
];
function parse(res) {
return {
code: res.code,
msg: res.message,
count: res.count || 0,
data: res.data || []
};
}
function post(url, data, okTip, done) {
var loading = layer.load(2);
http.post(url, data).then(function (res) {
layer.close(loading);
if (res && res.code === 0) {
layer.msg(okTip || res.message || '操作成功', {icon: 1});
if (done) { done(res.data); }
} else {
layer.msg((res && res.message) || '操作失败', {icon: 2});
}
}).catch(function (err) {
layer.close(loading);
layer.msg('请求失败:' + (err && err.message ? err.message : '网络异常'), {icon: 2});
});
}
function checkedTables() {
var rows = table.checkStatus('tableList').data || [];
var names = [];
for (var i = 0; i < rows.length; i++) {
names.push(rows[i].name);
}
return names;
}
/* ====================== 建表弹窗 ====================== */
/**
* 单行字段
*/
function fieldRow(data) {
data = data || {};
var opts = '';
for (var i = 0; i < TYPES.length; i++) {
opts += '<option value="' + TYPES[i] + '"' +
(data.type === TYPES[i] ? ' selected' : '') + '>' + TYPES[i] + '</option>';
}
return '' +
'<div class="layui-form-item field-row" style="margin-bottom:4px">' +
' <div class="layui-inline" style="width:120px"><input type="text" name="name" value="' + (data.name || '') + '" placeholder="字段名" class="layui-input layui-input-sm"></div>' +
' <div class="layui-inline" style="width:120px"><select name="type">' + opts + '</select></div>' +
' <div class="layui-inline" style="width:70px"><input type="text" name="length" value="' + (data.length || '') + '" placeholder="长度" class="layui-input"></div>' +
' <div class="layui-inline" style="width:100px"><input type="text" name="default" value="' + (data.default || '') + '" placeholder="默认值" class="layui-input"></div>' +
' <div class="layui-inline" style="width:130px"><input type="text" name="comment" value="' + (data.comment || '') + '" placeholder="注释" class="layui-input"></div>' +
' <div class="layui-inline" style="width:74px"><input type="checkbox" name="unsigned" value="1" title="无符号"' + (data.unsigned ? ' checked' : '') + '></div>' +
' <div class="layui-inline" style="width:60px"><input type="checkbox" name="nullable" value="1" title="可空"' + (data.nullable ? ' checked' : '') + '></div>' +
' <div class="layui-inline" style="width:100px"><input type="checkbox" name="autoinc" value="1" title="自增"' + (data.autoinc ? ' checked' : '') + '></div>' +
' <div class="layui-inline" style="width:60px"><input type="checkbox" name="pk" value="1" title="主键"' + (data.pk ? ' checked' : '') + '></div>' +
' <div class="layui-inline" style="width:90px"><select name="index">' +
' <option value="">无索引</option>' +
' <option value="index"' + (data.index === 'index' ? ' selected' : '') + '>普通</option>' +
' <option value="unique"' + (data.index === 'unique' ? ' selected' : '') + '>唯一</option>' +
' </select></div>' +
' <button type="button" class="layui-btn layui-btn-xs layui-btn-danger" data-act="remove-field">删</button>' +
'</div>';
}
function addRow(data) {
$('#fieldRows').append(fieldRow(data));
form.render(null, 'create-form');
}
/**
* 收集表单里的字段定义
*/
function collect() {
var base = form.val('create-form');
var fields = [];
$('#fieldRows .field-row').each(function () {
var r = $(this);
fields.push({
name: $.trim(r.find('input[name=name]').val() || ''),
type: r.find('select[name=type]').val(),
length: $.trim(r.find('input[name=length]').val() || ''),
default: $.trim(r.find('input[name=default]').val() || ''),
comment: $.trim(r.find('input[name=comment]').val() || ''),
unsigned: r.find('input[name=unsigned]').is(':checked') ? 1 : 0,
nullable: r.find('input[name=nullable]').is(':checked') ? 1 : 0,
autoinc: r.find('input[name=autoinc]').is(':checked') ? 1 : 0,
pk: r.find('input[name=pk]').is(':checked') ? 1 : 0,
index: r.find('select[name=index]').val()
});
});
return {
table: $.trim(base.table || ''),
comment: $.trim(base.comment || ''),
engine: base.engine,
charset: base.charset,
collation: '',
fields: fields
};
}
function openCreate() {
var idx = layer.open({
type: 1,
title: '新建数据表',
area: ['1080px', '640px'],
content: $('#createTpl').html(),
success: function () {
form.render(null, 'create-form');
$('#fieldRows').html('');
addRow({name: 'id', type: 'bigint', unsigned: 1, autoinc: 1, pk: 1, comment: 'ID'});
addRow({name: 'name', type: 'varchar', length: '100', default: '', comment: '名称'});
addRow({name: 'create_at', type: 'int', length: '', default: '0', comment: '创建时间'});
element.render();
}
});
$(document).off('click', '#btn-add-field').on('click', '#btn-add-field', function () {
addRow({type: 'varchar', length: '255'});
});
$(document).off('click', '#btn-add-id').on('click', '#btn-add-id', function () {
addRow({name: 'id', type: 'bigint', unsigned: 1, autoinc: 1, pk: 1, comment: 'ID'});
});
$(document).off('click', '[data-act=remove-field]').on('click', '[data-act=remove-field]', function () {
if ($('#fieldRows .field-row').length <= 1) {
return layer.msg('至少保留一个字段', {icon: 2});
}
$(this).closest('.field-row').remove();
});
// 生成预览(不执行)
$(document).off('click', '#btn-preview').on('click', '#btn-preview', function () {
var payload = collect();
if (payload.table === '') {
return layer.msg('请填写表名', {icon: 2});
}
post(FUNC.preview, payload, '', function (d) {
$('#sqlPreview').val(d.sql);
layer.msg('已生成,请确认后执行', {icon: 1});
});
});
// 执行建表
$(document).off('click', '#btn-exec').on('click', '#btn-exec', function () {
var payload = collect();
if (payload.table === '') {
return layer.msg('请填写表名', {icon: 2});
}
layer.confirm(
'确定创建表 <b>' + payload.table + '</b>?<br><span class="layui-word-aux">建议先「生成 SQL 预览」确认无误</span>',
{title: '建表确认'},
function (i) {
layer.close(i);
post(FUNC.create, payload, '创建成功', function (d) {
layer.close(idx);
layer.alert(
'表:<b>' + d.table + '</b><br>下一步生成 CRUD:<br><code>' + d.command + '</code>',
{title: '建表完成', btn: ['知道了']}
);
table.reload('tableList');
});
}
);
});
}
/* ====================== 结构弹窗 ====================== */
function openStructure(name) {
http.get(FUNC.structure, {table: name}).then(function (res) {
if (!res || res.code !== 0) {
return layer.msg((res && res.message) || '读取失败', {icon: 2});
}
var d = res.data;
var html = '';
for (var i = 0; i < d.columns.length; i++) {
var c = d.columns[i];
html += '<tr><td>' + c.field + '</td><td>' + c.type + '</td><td>' + c.null + '</td>' +
'<td>' + (c.default === null ? 'NULL' : c.default) + '</td>' +
'<td>' + (c.key || '-') + '</td><td>' + (c.extra || '-') + '</td><td>' + c.comment + '</td></tr>';
}
layer.open({
type: 1,
title: '表结构:' + name,
area: ['900px', '580px'],
content: $('#structureTpl').html(),
success: function () {
$('#columnBody').html(html || '<tr><td colspan="7">无字段</td></tr>');
$('#createSqlText').val(d.sql);
element.render();
}
});
}).catch(function (err) {
layer.msg('请求失败:' + (err && err.message ? err.message : '网络异常'), {icon: 2});
});
}
/* ====================== 导出安装 SQL ====================== */
/**
* 取选中表的 CREATE TABLE(__PREFIX__ 形式),弹窗展示 + 复制 / 下载
*/
function openExport(names) {
if (!names.length) {
return layer.msg('请先勾选要导出的表', {icon: 2});
}
var loading = layer.load(2);
http.post(FUNC.export, {tables: names}).then(function (res) {
layer.close(loading);
if (!res || res.code !== 0) {
return layer.msg((res && res.message) || '导出失败', {icon: 2});
}
var d = res.data;
var rows = '';
for (var i = 0; i < d.items.length; i++) {
var it = d.items[i];
var badge = it.registered
? '<span class="layui-badge layui-bg-green">已归档</span>'
: '<span class="layui-badge layui-bg-orange">未归档</span>';
rows += '<tr><td><code>' + it.short + '</code></td><td>' + badge + '</td>' +
'<td>' + (it.target ? '<code>' + it.target + '</code>' : '<span class="layui-word-aux">需人工指定</span>') + '</td></tr>';
}
layer.open({
type: 1,
title: '导出安装 SQL(' + d.items.length + ' 张表)',
area: ['860px', '640px'],
content: $('#exportTpl').html(),
success: function () {
$('#exportBody').html(rows);
$('#exportSqlText').val(d.text);
$('#exportSummary').html(
(d.unregistered && d.unregistered.length
? '其中未归档:' + d.unregistered.join('、') + ',请确认写入哪个 install.sql。'
: '所选表均已登记,导出可用于核对结构是否漂移。')
);
}
});
$(document).off('click', '#btn-copy-sql').on('click', '#btn-copy-sql', function () {
var el = document.getElementById('exportSqlText');
el.select();
try {
document.execCommand('copy');
layer.msg('已复制', {icon: 1});
} catch (e) {
layer.msg('复制失败,请手动选中复制', {icon: 2});
}
});
$(document).off('click', '#btn-download-sql').on('click', '#btn-download-sql', function () {
var blob = new Blob([d.text], {type: 'text/sql;charset=utf-8'});
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'install_' + new Date().getTime() + '.sql';
a.click();
setTimeout(function () { URL.revokeObjectURL(a.href); }, 1000);
});
}).catch(function (err) {
layer.close(loading);
layer.msg('请求失败:' + (err && err.message ? err.message : '网络异常'), {icon: 2});
});
}
/* ====================== 删表 ====================== */
function dropTable(name) {
layer.prompt({
title: '危险:删除表不可恢复,请输入表名确认',
formType: 0,
value: ''
}, function (val, i) {
layer.close(i);
if (val !== name) {
return layer.msg('输入的表名不一致,已取消', {icon: 2});
}
post(FUNC.drop, {table: name, confirm: val}, '已删除', function () {
table.reload('tableList');
});
});
}
var obj = {
renderTable: function () {
table.render({
elem: '#tableList',
url: FUNC.index,
toolbar: '#tableBar',
defaultToolbar: ['filter'],
method: 'get',
cols: [[
{type: 'checkbox', width: 50},
{field: 'name', title: '表名', minWidth: 240},
{field: 'comment', title: '备注', minWidth: 160},
{field: 'engine', title: '引擎', width: 100},
{field: 'registered', title: '归档', width: 110, templet: function (d) {
return d.registered
? '<span class="layui-badge layui-bg-green">已归档</span>'
: '<span class="layui-badge layui-bg-orange" title="不在任何 install.sql,全新安装/自愈都不会建这张表">未归档</span>';
}},
{field: 'target', title: 'install.sql', minWidth: 170, templet: function (d) {
return d.target ? '<code>' + d.target + '</code>' : '<span class="layui-word-aux">—</span>';
}},
{field: 'rows', title: '行数', width: 110, sort: true},
{field: 'size_text', title: '大小', width: 110, sort: true},
{field: 'collation', title: '字符集', width: 160},
{title: '操作', width: 170, templet: '#tableAction', fixed: 'right'}
]],
page: true,
limit: 15,
limits: [15, 30, 50, 100],
response: {statusCode: 0},
parseData: parse,
done: function () {
var view = $('#tableList').next('.layui-table-view');
auth.filterDom(view);
auth.filterDom('.layui-btn[data-perm]');
}
});
},
events: function () {
table.on('toolbar(tableList)', function (o) {
if (o.event === 'create') {
openCreate();
} else if (o.event === 'reload') {
table.reload('tableList');
} else if (o.event === 'optimize') {
var names = checkedTables();
if (!names.length) {
return layer.msg('请先勾选要优化的表', {icon: 2});
}
post(FUNC.optimize, {tables: names}, '优化完成', function () {
table.reload('tableList');
});
} else if (o.event === 'export') {
openExport(checkedTables());
}
});
table.on('tool(tableList)', function (o) {
if (o.event === 'structure') {
openStructure(o.data.name);
} else if (o.event === 'optimizeRow') {
post(FUNC.optimize, {tables: [o.data.name]}, '优化完成', function () {
table.reload('tableList');
});
} else if (o.event === 'exportRow') {
openExport([o.data.name]);
} else if (o.event === 'drop') {
dropTable(o.data.name);
}
});
form.on('submit(data-search-btn)', function (o) {
table.reload('tableList', {
where: {keyword: o.field.keyword, unregistered: o.field.unregistered || '0'},
page: {curr: 1}
});
return false;
});
$('#btn-reset').on('click', function () {
setTimeout(function () {
table.reload('tableList', {where: {keyword: '', unregistered: '0'}, page: {curr: 1}});
}, 10);
});
}
};
form.render(null, 'search-form'); // 归档筛选下拉要 layui 渲染,否则是原生 select
obj.renderTable();
obj.events();
exports('datatable', obj);
});