安全(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(市场 / 开发者中心 / 审核 / 分润)
1012 lines
41 KiB
PHP
1012 lines
41 KiB
PHP
<?php
|
||
// +----------------------------------------------------------------------
|
||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||
// +----------------------------------------------------------------------
|
||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||
// +----------------------------------------------------------------------
|
||
// | Author: ywxapp<admin@ywxapp.cn>
|
||
// +----------------------------------------------------------------------
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace ywxapp\command;
|
||
|
||
use think\console\Command;
|
||
use think\console\Input;
|
||
use think\console\Output;
|
||
use think\console\input\Argument;
|
||
use think\console\input\Option;
|
||
use think\facade\Db;
|
||
|
||
/**
|
||
* 插件后台 CRUD 生成器
|
||
*
|
||
* 由一张数据表生成插件后台整套增删改查代码,并自动注册路由与菜单权限节点:
|
||
* php think addon:crud <插件名> -t <表名> [--name=类名] [--title=菜单标题] [--force]
|
||
*
|
||
* 生成物:
|
||
* 1. addon/<插件>/model/<Model>.php 模型(表名/时间戳/软删除/类型转换)
|
||
* 2. addon/<插件>/controller/backend/<Ctrl>.php 控制器(index/add/update/delete)
|
||
* 3. addon/<插件>/view/backend/<ctrl>/index.html 列表页 + 弹窗表单模板
|
||
* 4. public/static/backend/modules/<ctrl>.js layui 表格 + 弹窗交互
|
||
* 5. addon/<插件>/route/app.php 追加 backend 路由(--no-route 可关闭)
|
||
* 6. addon/<插件>/menu.json 追加菜单与按钮权限节点(--no-menu 可关闭)
|
||
*
|
||
* 权限约定(与 Auth::verifyAuth / AddonService::saveMenus / auth.js 三方对齐):
|
||
* 菜单节点 backend_power.name = <插件>:backend:<控制器>/index
|
||
* 按钮节点 backend_power.name = <插件>:backend:<控制器>:add|update|delete
|
||
* 前端判定 auth.setController('<插件>:backend:<控制器>') + data-perm="add|update|delete"
|
||
* 后端判定 控制器动作名即 add/update/delete,与按钮节点一一对应
|
||
*
|
||
* @author ywxapp <admin@ywxapp.cn>
|
||
*/
|
||
class AddonCrud extends Command
|
||
{
|
||
/**
|
||
* 时间戳字段(不出现在表单、不参与白名单)
|
||
*/
|
||
private const TIME_FIELDS = ['create_at', 'update_at', 'delete_at', 'create_time', 'update_time', 'delete_time'];
|
||
|
||
/**
|
||
* 判为图片/文件的字段名(生成上传类输入框)
|
||
*/
|
||
private const MEDIA_FIELDS = ['cover', 'image', 'images', 'avatar', 'thumb', 'logo', 'photo', 'pic', 'banner', 'file', 'url'];
|
||
|
||
/**
|
||
* 判为大文本的字段名(生成 textarea)
|
||
*/
|
||
private const TEXT_FIELDS = ['content', 'detail', 'remark', 'description', 'intro', 'descr', 'summary', 'note', 'memo'];
|
||
|
||
/**
|
||
* 列表页可参与关键字搜索的字段名
|
||
*/
|
||
private const SEARCH_FIELDS = ['name', 'title', 'username', 'nickname', 'mobile', 'phone', 'email', 'keyword', 'alias', 'no', 'sn'];
|
||
|
||
/**
|
||
* {@inheritdoc}
|
||
*/
|
||
protected function configure()
|
||
{
|
||
$this->setName('addon:crud')
|
||
->addArgument('addon', Argument::OPTIONAL, '插件名,如 articles(--app=backend 时留空)')
|
||
->addOption('table', 't', Option::VALUE_REQUIRED, '数据表名(可省略表前缀)')
|
||
->addOption('app', null, Option::VALUE_OPTIONAL, '生成到核心后台:--app=backend(默认生成到插件)')
|
||
->addOption('name', null, Option::VALUE_OPTIONAL, '控制器 / 模型类名,默认由表名推导')
|
||
->addOption('title', null, Option::VALUE_OPTIONAL, '菜单标题,默认取表注释')
|
||
->addOption('icon', null, Option::VALUE_OPTIONAL, '菜单图标')
|
||
->addOption('pid', null, Option::VALUE_OPTIONAL, '核心后台菜单父级 ID,默认 0(顶级)')
|
||
->addOption('force', 'f', Option::VALUE_NONE, '覆盖已存在的同名文件')
|
||
->addOption('no-menu', null, Option::VALUE_NONE, '不写入 menu.json / 权限表')
|
||
->addOption('no-route', null, Option::VALUE_NONE, '不写入 route/app.php')
|
||
->setDescription('按数据表生成后台 CRUD:model / controller / view / js + 路由 + 菜单权限节点(插件或核心后台)');
|
||
}
|
||
|
||
protected function execute(Input $input, Output $output)
|
||
{
|
||
$addon = trim((string) $input->getArgument('addon'));
|
||
$table = trim((string) $input->getOption('table'));
|
||
$mode = strtolower(trim((string) $input->getOption('app'))) === 'backend' ? 'backend' : 'addon';
|
||
|
||
if ($table === '') {
|
||
$output->writeln('<error>请通过 -t/--table 指定数据表,示例:php think addon:crud articles -t articles_category</error>');
|
||
return 1;
|
||
}
|
||
|
||
$root = $this->app->getRootPath();
|
||
if ($mode === 'addon') {
|
||
if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $addon)) {
|
||
$output->writeln('<error>插件名不合法,示例:php think addon:crud articles -t articles_category</error>');
|
||
return 1;
|
||
}
|
||
$addonDir = $root . 'addon' . DIRECTORY_SEPARATOR . $addon . DIRECTORY_SEPARATOR;
|
||
if (!is_dir($addonDir)) {
|
||
$output->writeln("<error>插件目录不存在:addon/{$addon},请先执行 php think addon:make {$addon} -a build</error>");
|
||
return 1;
|
||
}
|
||
$modelNs = 'addon\\' . $addon . '\\model';
|
||
$ctrlNs = 'addon\\' . $addon . '\\controller\\backend';
|
||
$modelDir = $addonDir . 'model';
|
||
$ctrlDir = $addonDir . 'controller' . DIRECTORY_SEPARATOR . 'backend';
|
||
$viewBase = $addonDir . 'view' . DIRECTORY_SEPARATOR . 'backend';
|
||
$routeFile = $addonDir . 'route' . DIRECTORY_SEPARATOR . 'app.php';
|
||
$defIcon = 'fa fa-list';
|
||
} else {
|
||
// 核心后台:app/backend/{model,controller,view,route}
|
||
$addon = '';
|
||
$addonDir = $root . 'app' . DIRECTORY_SEPARATOR . 'backend' . DIRECTORY_SEPARATOR;
|
||
$modelNs = 'app\\backend\\model';
|
||
$ctrlNs = 'app\\backend\\controller';
|
||
$modelDir = $addonDir . 'model';
|
||
$ctrlDir = $addonDir . 'controller';
|
||
$viewBase = $addonDir . 'view';
|
||
$routeFile = $addonDir . 'route' . DIRECTORY_SEPARATOR . 'app.php';
|
||
$defIcon = 'layui-icon layui-icon-template-1';
|
||
}
|
||
|
||
/* ---------------------------- 读取表结构 ---------------------------- */
|
||
|
||
$prefix = $this->tablePrefix();
|
||
$fullTable = str_starts_with($table, $prefix) ? $table : $prefix . $table;
|
||
$rawTable = substr($fullTable, strlen($prefix));
|
||
|
||
try {
|
||
$columns = Db::query('SHOW FULL COLUMNS FROM `' . $fullTable . '`');
|
||
} catch (\Throwable $e) {
|
||
$output->writeln('<error>读取表结构失败:' . $fullTable . '(' . $e->getMessage() . ')</error>');
|
||
return 1;
|
||
}
|
||
if (empty($columns)) {
|
||
$output->writeln("<error>表不存在或无字段:{$fullTable}</error>");
|
||
return 1;
|
||
}
|
||
|
||
$fields = $this->normalizeColumns($columns);
|
||
$uniques = $this->uniqueFields($fullTable);
|
||
$comment = $this->tableComment($fullTable);
|
||
|
||
/* ------------------------------ 命名推导 ------------------------------ */
|
||
|
||
// 表名去掉插件名前缀后再转驼峰:articles_category → Category(与 articles 插件命名一致)
|
||
$base = $rawTable;
|
||
if ($mode === 'addon' && str_starts_with($base, $addon . '_')) {
|
||
$base = substr($base, strlen($addon) + 1);
|
||
}
|
||
$class = $this->studly($input->getOption('name') ?: $base);
|
||
$viewDir = $this->snake($class); // Category → category;ArticleCategory → article_category
|
||
$route = $viewDir;
|
||
$varName = $this->camel($class); // 模板变量名 / 表单 filter 名
|
||
$title = trim((string) ($input->getOption('title') ?: '')) ?: ($comment ?: $class);
|
||
$icon = trim((string) ($input->getOption('icon') ?: '')) ?: $defIcon;
|
||
// 权限前缀:插件为 <插件>:backend:<控制器>;核心后台为 <控制器>
|
||
// (Auth::verifyAuth 对核心后台用 controller:action 精确匹配,且 controller 会转小写)
|
||
$perm = $mode === 'addon' ? ($addon . ':backend:' . $viewDir) : strtolower($class);
|
||
$force = (bool) $input->getOption('force');
|
||
|
||
$output->writeln($mode === 'addon'
|
||
? "<comment>表 {$fullTable} → 插件 {$addon} / 类 {$class}</comment>"
|
||
: "<comment>表 {$fullTable} → 核心后台 / 类 {$class}</comment>");
|
||
|
||
/* ------------------------------ 生成文件 ------------------------------ */
|
||
|
||
$ctx = [
|
||
'mode' => $mode,
|
||
'addon' => $addon,
|
||
'class' => $class,
|
||
'modelNs' => $modelNs, // 模型所在命名空间(不含类名)
|
||
'ctrlNs' => $ctrlNs, // 控制器所在命名空间
|
||
'table' => $rawTable,
|
||
'full' => $fullTable,
|
||
'viewDir' => $viewDir,
|
||
'route' => $route,
|
||
'var' => $varName,
|
||
'title' => $title,
|
||
'icon' => $icon,
|
||
'perm' => $perm,
|
||
// 按钮权限全名前缀:data-perm="{前缀}add" → 如 articles:backend:tag:add / database:add
|
||
// 核心后台既有页面(如 links)用完整权限名,插件后台同样适用,故统一用全名
|
||
'permPrefix' => $perm . ':',
|
||
'fields' => $fields,
|
||
'uniques' => $uniques,
|
||
'pk' => $this->primaryKey($fields),
|
||
];
|
||
|
||
$files = [
|
||
$modelDir . DIRECTORY_SEPARATOR . $class . '.php'
|
||
=> $this->renderModel($ctx),
|
||
$ctrlDir . DIRECTORY_SEPARATOR . $class . '.php'
|
||
=> $this->renderController($ctx),
|
||
$viewBase . DIRECTORY_SEPARATOR . $viewDir . DIRECTORY_SEPARATOR . 'index.html'
|
||
=> $this->renderView($ctx),
|
||
public_path() . 'static' . DIRECTORY_SEPARATOR . 'backend' . DIRECTORY_SEPARATOR . 'modules' . DIRECTORY_SEPARATOR . $viewDir . '.js'
|
||
=> $this->renderJs($ctx),
|
||
];
|
||
|
||
$written = 0;
|
||
foreach ($files as $path => $content) {
|
||
if (is_file($path) && !$force) {
|
||
$output->writeln('<comment>已存在,跳过(加 -f 覆盖):' . $this->short($path) . '</comment>');
|
||
continue;
|
||
}
|
||
if (!is_dir(dirname($path))) {
|
||
mkdir(dirname($path), 0755, true);
|
||
}
|
||
file_put_contents($path, $this->clean($content));
|
||
$written++;
|
||
$output->writeln('<info>生成 ' . $this->short($path) . '</info>');
|
||
}
|
||
|
||
/* --------------------------- 路由 / 菜单 --------------------------- */
|
||
|
||
$routeTip = $mode === 'addon' ? 'addon/' . $addon . '/route/app.php' : 'app/backend/route/app.php';
|
||
|
||
if (!$input->getOption('no-route')) {
|
||
$res = $this->appendRoute($routeFile, $ctx);
|
||
$output->writeln($res === true
|
||
? '<info>已追加后台路由到 ' . $routeTip . '</info>'
|
||
: '<comment>' . $res . '</comment>');
|
||
}
|
||
if (!$input->getOption('no-menu')) {
|
||
if ($mode === 'addon') {
|
||
$res = $this->appendMenu($addonDir . 'menu.json', $ctx);
|
||
$ok = '<info>已追加菜单与按钮权限到 addon/' . $addon . '/menu.json</info>';
|
||
} else {
|
||
$res = $this->appendPower($ctx, (int) $input->getOption('pid'));
|
||
$ok = '<info>已写入菜单与按钮权限到 backend_power 表</info>';
|
||
}
|
||
$output->writeln($res === true ? $ok : '<comment>' . $res . '</comment>');
|
||
}
|
||
|
||
$output->writeln('');
|
||
$output->writeln("<info>CRUD 生成完成(写入 {$written} 个文件)</info>");
|
||
$output->writeln(' 权限前缀:' . $perm);
|
||
$output->writeln($mode === 'addon'
|
||
? ' 后续:php think addon:manage ' . $addon . ' -a reload # 重载菜单使新节点生效'
|
||
: ' 后续:刷新后台即可看到新菜单(核心后台权限直接读 backend_power)');
|
||
|
||
return 0;
|
||
}
|
||
|
||
/* ============================ 渲染:模型 ============================ */
|
||
|
||
protected function renderModel(array $ctx): string
|
||
{
|
||
$names = array_column($ctx['fields'], 'field');
|
||
$hasCreate = in_array('create_at', $names, true);
|
||
$hasUpdate = in_array('update_at', $names, true);
|
||
$hasDelete = in_array('delete_at', $names, true);
|
||
|
||
$use = [];
|
||
$traits = '';
|
||
if ($hasDelete) {
|
||
$use[] = 'use think\model\concern\SoftDelete;';
|
||
$traits = "\n use SoftDelete;\n";
|
||
}
|
||
|
||
$lines = [];
|
||
$lines[] = ' protected $name = \'' . $ctx['table'] . '\';';
|
||
if ($ctx['pk'] !== 'id') {
|
||
$lines[] = ' protected $pk = \'' . $ctx['pk'] . '\';';
|
||
}
|
||
if ($hasCreate || $hasUpdate) {
|
||
$lines[] = ' protected $autoWriteTimestamp = \'int\';';
|
||
$lines[] = ' protected $createTime = \'' . ($hasCreate ? 'create_at' : false) . '\';';
|
||
$lines[] = ' protected $updateTime = \'' . ($hasUpdate ? 'update_at' : false) . '\';';
|
||
} else {
|
||
$lines[] = ' protected $autoWriteTimestamp = false;';
|
||
}
|
||
if ($hasDelete) {
|
||
$lines[] = ' protected $deleteTime = \'delete_at\';';
|
||
$lines[] = ' protected $defaultSoftDelete = 0;';
|
||
}
|
||
$types = $this->modelTypes($ctx['fields']);
|
||
if ($types !== []) {
|
||
$lines[] = ' protected $type = [';
|
||
foreach ($types as $field => $type) {
|
||
$lines[] = ' \'' . $field . '\' => \'' . $type . '\',';
|
||
}
|
||
$lines[] = ' ];';
|
||
}
|
||
|
||
$stub = $this->stub('model.stub');
|
||
return str_replace(
|
||
['{%use%}', '{%traits%}', '{%namespace%}', '{%className%}', '{%table%}', '{%options%}'],
|
||
[
|
||
$use ? implode("\n", $use) . "\n" : '',
|
||
$traits,
|
||
$ctx['modelNs'],
|
||
$ctx['class'],
|
||
$ctx['table'],
|
||
implode("\n", $lines),
|
||
],
|
||
$stub
|
||
);
|
||
}
|
||
|
||
/* ========================== 渲染:控制器 ========================== */
|
||
|
||
protected function renderController(array $ctx): string
|
||
{
|
||
$model = $ctx['class'];
|
||
$editable = $this->editableFields($ctx['fields']);
|
||
$allow = implode(', ', array_map(fn($f) => '\'' . $f['field'] . '\'', $editable));
|
||
$rules = $this->validateRules($ctx);
|
||
$search = $this->searchSnippet($ctx['fields']);
|
||
|
||
$stub = $this->stub('controller.stub');
|
||
return str_replace(
|
||
['{%namespace%}', '{%addon%}', '{%className%}', '{%model%}', '{%modelNs%}', '{%modelAlias%}', '{%viewDir%}', '{%title%}', '{%pk%}', '{%allow%}', '{%rules%}', '{%search%}'],
|
||
[
|
||
$ctx['ctrlNs'],
|
||
$ctx['addon'],
|
||
$ctx['class'],
|
||
$model,
|
||
$ctx['modelNs'] . '\\' . $model,
|
||
$model . 'Model',
|
||
$ctx['viewDir'],
|
||
$ctx['title'],
|
||
$ctx['pk'],
|
||
$allow,
|
||
$rules,
|
||
$search,
|
||
],
|
||
$stub
|
||
);
|
||
}
|
||
|
||
/* ========================== 渲染:视图 ========================== */
|
||
|
||
protected function renderView(array $ctx): string
|
||
{
|
||
$editable = $this->editableFields($ctx['fields']);
|
||
$items = [];
|
||
foreach ($editable as $f) {
|
||
$items[] = $this->formItem($f, $ctx);
|
||
}
|
||
|
||
$hasStatus = false;
|
||
foreach ($ctx['fields'] as $f) {
|
||
if ($f['field'] === 'status') {
|
||
$hasStatus = true;
|
||
}
|
||
}
|
||
|
||
$searchBar = $this->searchBar($ctx['fields']);
|
||
|
||
$stub = $this->stub('view.index.stub');
|
||
return str_replace(
|
||
['{%title%}', '{%module%}', '{%var%}', '{%permPrefix%}', '{%searchBar%}', '{%statusTpl%}', '{%formItems%}'],
|
||
[
|
||
$ctx['title'],
|
||
$ctx['viewDir'],
|
||
$ctx['var'],
|
||
$ctx['permPrefix'],
|
||
$searchBar,
|
||
$hasStatus ? $this->statusTpl() : '',
|
||
implode("\n", $items),
|
||
],
|
||
$stub
|
||
);
|
||
}
|
||
|
||
/* =========================== 渲染:JS =========================== */
|
||
|
||
protected function renderJs(array $ctx): string
|
||
{
|
||
$cols = [];
|
||
foreach ($ctx['fields'] as $f) {
|
||
$col = $this->tableCol($f);
|
||
if ($col === '') {
|
||
continue; // 如 delete_at,不进列表
|
||
}
|
||
$cols[] = ' ' . $col;
|
||
}
|
||
|
||
$hasSearch = $this->searchFields($ctx['fields']) !== [];
|
||
|
||
$stub = $this->stub('js.stub');
|
||
return str_replace(
|
||
['{%title%}', '{%module%}', '{%perm%}', '{%permPrefix%}', '{%addon%}', '{%route%}', '{%var%}', '{%cols%}', '{%searchEvent%}'],
|
||
[
|
||
$ctx['title'],
|
||
$ctx['viewDir'],
|
||
$ctx['perm'],
|
||
$ctx['permPrefix'],
|
||
$ctx['addon'],
|
||
$ctx['route'],
|
||
$ctx['var'],
|
||
implode(",\n", $cols),
|
||
$hasSearch ? $this->searchEvent() : '',
|
||
],
|
||
$stub
|
||
);
|
||
}
|
||
|
||
/* ========================= 字段 → 控件映射 ========================= */
|
||
|
||
/**
|
||
* 单个字段对应的 layui 表单项 HTML
|
||
*/
|
||
protected function formItem(array $f, array $ctx): string
|
||
{
|
||
$name = $f['field'];
|
||
$label = $f['comment'] ?: $this->labelOf($name);
|
||
$type = $f['base'];
|
||
$req = $f['required'] ? ' required lay-verify="required"' : '';
|
||
|
||
$html = '';
|
||
$html .= ' <div class="layui-form-item">' . "\n";
|
||
$html .= ' <label class="layui-form-label">' . $label . '</label>' . "\n";
|
||
|
||
// 开关/状态:tinyint 且字段名含 status / is_ / enable
|
||
if ($type === 'tinyint' && (in_array($name, ['status', 'enable', 'enabled'], true) || str_starts_with($name, 'is_'))) {
|
||
$html .= ' <div class="layui-input-block">' . "\n";
|
||
$html .= ' <input type="radio" name="' . $name . '" value="1" title="是" {{ d.' . $name . '==undefined || d.' . $name . '==1 ? \'checked\' : \'\' }}>' . "\n";
|
||
$html .= ' <input type="radio" name="' . $name . '" value="0" title="否" {{ d.' . $name . '==0 ? \'checked\' : \'\' }}>' . "\n";
|
||
$html .= ' </div>' . "\n";
|
||
$html .= ' </div>';
|
||
return $html;
|
||
}
|
||
|
||
// 枚举:生成下拉
|
||
if (in_array($type, ['enum', 'set'], true) && $f['options'] !== []) {
|
||
$html .= ' <div class="layui-input-block">' . "\n";
|
||
$html .= ' <select name="' . $name . '"' . ($f['required'] ? ' lay-verify="required"' : '') . '>' . "\n";
|
||
$html .= ' <option value="">请选择</option>' . "\n";
|
||
foreach ($f['options'] as $opt) {
|
||
$html .= ' <option value="' . $opt . '" {{ d.' . $name . '==\'' . $opt . '\' ? \'selected\' : \'\' }}>' . $opt . '</option>' . "\n";
|
||
}
|
||
$html .= ' </select>' . "\n";
|
||
$html .= ' </div>' . "\n";
|
||
$html .= ' </div>';
|
||
return $html;
|
||
}
|
||
|
||
// 图片 / 文件字段
|
||
if (in_array($name, self::MEDIA_FIELDS, true)) {
|
||
$html .= ' <div class="layui-input-block">' . "\n";
|
||
$html .= ' <input type="text" name="' . $name . '"' . $req . ' placeholder="请填写地址,或接入上传组件后选择文件" autocomplete="off" class="layui-input" value="{{d.' . $name . '||\'\'}}">' . "\n";
|
||
$html .= ' </div>' . "\n";
|
||
$html .= ' </div>';
|
||
return $html;
|
||
}
|
||
|
||
// 大文本
|
||
if (in_array($type, ['text', 'tinytext', 'mediumtext', 'longtext'], true) || in_array($name, self::TEXT_FIELDS, true)) {
|
||
$html .= ' <div class="layui-input-block">' . "\n";
|
||
$html .= ' <textarea name="' . $name . '"' . ($f['required'] ? ' lay-verify="required"' : '') . ' placeholder="请输入' . $label . '" class="layui-textarea">{{d.' . $name . '||\'\'}}</textarea>' . "\n";
|
||
$html .= ' </div>' . "\n";
|
||
$html .= ' </div>';
|
||
return $html;
|
||
}
|
||
|
||
// 数字
|
||
if (in_array($type, ['int', 'bigint', 'smallint', 'mediumint', 'tinyint', 'decimal', 'float', 'double'], true)) {
|
||
$html .= ' <div class="layui-input-block">' . "\n";
|
||
$html .= ' <input type="number" name="' . $name . '"' . $req . ' placeholder="请输入' . $label . '" autocomplete="off" class="layui-input" value="{{d.' . $name . '||0}}">' . "\n";
|
||
$html .= ' </div>' . "\n";
|
||
$html .= ' </div>';
|
||
return $html;
|
||
}
|
||
|
||
// 日期时间(date/datetime 类型,非 int 时间戳)
|
||
if (in_array($type, ['date', 'datetime', 'time'], true)) {
|
||
$html .= ' <div class="layui-input-block">' . "\n";
|
||
$html .= ' <input type="text" name="' . $name . '"' . $req . ' class="layui-input" id="' . $ctx['var'] . '-' . $name . '" placeholder="请选择' . $label . '" value="{{d.' . $name . '||\'\'}}">' . "\n";
|
||
$html .= ' </div>' . "\n";
|
||
$html .= ' </div>';
|
||
return $html;
|
||
}
|
||
|
||
// 默认文本框
|
||
$html .= ' <div class="layui-input-block">' . "\n";
|
||
$html .= ' <input type="text" name="' . $name . '"' . $req . ' placeholder="请输入' . $label . '" autocomplete="off" class="layui-input" value="{{d.' . $name . '||\'\'}}">' . "\n";
|
||
$html .= ' </div>' . "\n";
|
||
$html .= ' </div>';
|
||
return $html;
|
||
}
|
||
|
||
/**
|
||
* 字段对应的 layui 表格列配置
|
||
*/
|
||
protected function tableCol(array $f): string
|
||
{
|
||
$name = $f['field'];
|
||
$title = $f['comment'] ?: $this->labelOf($name);
|
||
$type = $f['base'];
|
||
|
||
if ($name === 'status') {
|
||
return "{field: '{$name}', title: '{$title}', width: 90, templet: '#statusTpl'}";
|
||
}
|
||
if (in_array($name, ['create_at', 'update_at'], true)) {
|
||
$title = $name === 'create_at' ? '创建时间' : '更新时间';
|
||
return "{field: '{$name}', title: '{$title}', width: 170, templet: function (d) {\n"
|
||
. " return d.{$name} ? layui.util.toDateString(d.{$name} * 1000, 'yyyy-MM-dd HH:mm:ss') : '';\n"
|
||
. " }}";
|
||
}
|
||
if ($name === 'delete_at') {
|
||
return '';
|
||
}
|
||
// 大文本不直接展示,但仍需进入行数据(hide:true 保留数据供编辑回填)
|
||
if (in_array($type, ['text', 'tinytext', 'mediumtext', 'longtext'], true) || in_array($name, self::TEXT_FIELDS, true)) {
|
||
return "{field: '{$name}', title: '{$title}', hide: true}";
|
||
}
|
||
if (in_array($type, ['int', 'bigint', 'smallint', 'mediumint', 'tinyint'], true)) {
|
||
$width = in_array($name, ['sort', 'weight'], true) ? ', width: 90' : '';
|
||
return "{field: '{$name}', title: '{$title}'{$width}, sort: true}";
|
||
}
|
||
return "{field: '{$name}', title: '{$title}'}";
|
||
}
|
||
|
||
/**
|
||
* 生成 ThinkPHP Validate 规则数组文本
|
||
*/
|
||
protected function validateRules(array $ctx): string
|
||
{
|
||
$lines = [];
|
||
foreach ($this->editableFields($ctx['fields']) as $f) {
|
||
$rules = [];
|
||
if ($f['required']) {
|
||
$rules[] = 'require';
|
||
}
|
||
if (in_array($f['base'], ['int', 'bigint', 'smallint', 'mediumint', 'tinyint', 'decimal', 'float', 'double'], true)) {
|
||
$rules[] = 'number';
|
||
}
|
||
if (in_array($f['base'], ['char', 'varchar'], true) && $f['length'] > 0) {
|
||
$rules[] = 'max:' . $f['length'];
|
||
}
|
||
if (in_array($f['field'], $ctx['uniques'], true)) {
|
||
$rules[] = 'unique:' . $ctx['table'] . ',' . $f['field'];
|
||
}
|
||
if ($rules === []) {
|
||
continue;
|
||
}
|
||
$lines[] = ' \'' . $f['field'] . '|' . ($f['comment'] ?: $this->labelOf($f['field'])) . '\' => \'' . implode('|', $rules) . '\',';
|
||
}
|
||
return $lines ? "\n" . implode("\n", $lines) . "\n " : '';
|
||
}
|
||
|
||
/**
|
||
* 模型 $type 转换声明
|
||
*/
|
||
protected function modelTypes(array $fields): array
|
||
{
|
||
$types = [];
|
||
foreach ($fields as $f) {
|
||
if (in_array($f['base'], ['int', 'bigint', 'smallint', 'mediumint', 'tinyint'], true)) {
|
||
$types[$f['field']] = 'integer';
|
||
} elseif (in_array($f['base'], ['decimal', 'float', 'double'], true)) {
|
||
$types[$f['field']] = 'float';
|
||
}
|
||
}
|
||
unset($types['id']);
|
||
return $types;
|
||
}
|
||
|
||
/* ============================ 搜索相关 ============================ */
|
||
|
||
protected function searchFields(array $fields): array
|
||
{
|
||
$hit = [];
|
||
foreach ($fields as $f) {
|
||
if (in_array($f['field'], self::SEARCH_FIELDS, true) && in_array($f['base'], ['char', 'varchar'], true)) {
|
||
$hit[] = $f['field'];
|
||
}
|
||
}
|
||
return array_slice($hit, 0, 3);
|
||
}
|
||
|
||
protected function searchSnippet(array $fields): string
|
||
{
|
||
$hit = $this->searchFields($fields);
|
||
if ($hit === []) {
|
||
return '';
|
||
}
|
||
$fieldsStr = implode('|', $hit);
|
||
return "\n \$kw = trim((string) \$this->request->param('keyword', ''));\n"
|
||
. " if (\$kw !== '') {\n"
|
||
. " \$query->whereLike('{$fieldsStr}', '%' . \$kw . '%');\n"
|
||
. " }\n ";
|
||
}
|
||
|
||
protected function searchBar(array $fields): string
|
||
{
|
||
if ($this->searchFields($fields) === []) {
|
||
return '';
|
||
}
|
||
return ' <form class="layui-form" lay-filter="search-form" style="margin-bottom:10px">' . "\n"
|
||
. ' <div class="layui-inline">' . "\n"
|
||
. ' <input type="text" name="keyword" placeholder="关键字" autocomplete="off" class="layui-input" style="width:200px">' . "\n"
|
||
. ' </div>' . "\n"
|
||
. ' <button class="layui-btn" lay-submit lay-filter="search-submit">搜索</button>' . "\n"
|
||
. ' <button type="reset" class="layui-btn layui-btn-primary" id="search-reset">重置</button>' . "\n"
|
||
. ' </form>' . "\n";
|
||
}
|
||
|
||
protected function searchEvent(): string
|
||
{
|
||
return "\n // 关键字搜索\n"
|
||
. " form.on('submit(search-submit)', function (o) {\n"
|
||
. " table.reload('dataTable', { where: { keyword: o.field.keyword }, page: { curr: 1 } });\n"
|
||
. " return false;\n"
|
||
. " });\n"
|
||
. " \$('#search-reset').on('click', function () {\n"
|
||
. " setTimeout(function () { table.reload('dataTable', { where: { keyword: '' }, page: { curr: 1 } }); }, 10);\n"
|
||
. " });\n";
|
||
}
|
||
|
||
protected function statusTpl(): string
|
||
{
|
||
return '<!-- 状态列模板 -->' . "\n"
|
||
. '<script type="text/html" id="statusTpl">' . "\n"
|
||
. ' {{# if(d.status == 1) { }}' . "\n"
|
||
. ' <span class="layui-badge layui-bg-green">启用</span>' . "\n"
|
||
. ' {{# } else { }}' . "\n"
|
||
. ' <span class="layui-badge layui-bg-orange">禁用</span>' . "\n"
|
||
. ' {{# } }}' . "\n"
|
||
. '</script>' . "\n";
|
||
}
|
||
|
||
/* ========================== 路由 / 菜单 ========================== */
|
||
|
||
/**
|
||
* 向插件 route/app.php 的 backend 分组内追加路由
|
||
*
|
||
* @return bool|string true 成功,字符串为未写入原因
|
||
*/
|
||
protected function appendRoute(string $file, array $ctx)
|
||
{
|
||
if (!is_file($file)) {
|
||
return "路由文件不存在,已跳过:" . $this->short($file);
|
||
}
|
||
$content = (string) file_get_contents($file);
|
||
if (strpos($content, "'{$ctx['route']}/index'") !== false) {
|
||
return "路由已存在,未重复写入";
|
||
}
|
||
|
||
$block = " Route::get('{$ctx['route']}/index', '{$ctx['class']}/index');\n"
|
||
. " Route::post('{$ctx['route']}/add', '{$ctx['class']}/add');\n"
|
||
. " Route::post('{$ctx['route']}/update', '{$ctx['class']}/update');\n"
|
||
. " Route::post('{$ctx['route']}/delete', '{$ctx['class']}/delete');\n";
|
||
|
||
// 核心后台 route/app.php 是扁平结构(无 backend 分组),直接追加到文件末尾
|
||
if (($ctx['mode'] ?? 'addon') === 'backend') {
|
||
$new = rtrim($content) . "\n\n// {$ctx['title']}(addon:crud 生成)\n" . $block;
|
||
return $this->writeWithLint($file, $new, $content);
|
||
}
|
||
|
||
// 定位 Route::group('backend' ...) 块,在其闭合括号前插入
|
||
if (preg_match('/Route::group\(\s*[\'"]backend[\'"]/', $content, $m, PREG_OFFSET_CAPTURE)) {
|
||
$start = (int) $m[0][1]; // PREG_OFFSET_CAPTURE 给出字节偏移
|
||
$open = strpos($content, '(', $start);
|
||
$depth = 0;
|
||
$end = null;
|
||
for ($i = $open; $i < strlen($content); $i++) {
|
||
if ($content[$i] === '(') {
|
||
$depth++;
|
||
} elseif ($content[$i] === ')') {
|
||
$depth--;
|
||
if ($depth === 0) {
|
||
$end = $i;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if ($end !== null) {
|
||
// group 的闭合括号是 group('backend', function () { ... }) 最外层那个 ')';
|
||
// 代码块必须插在闭包体 '}' 之前——插在 '}' 与 ')' 之间会破坏语法。
|
||
$brace = strrpos(substr($content, 0, $end), '}');
|
||
if ($brace !== false) {
|
||
$new = substr($content, 0, $brace) . $block . substr($content, $brace);
|
||
return $this->writeWithLint($file, $new, $content);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 没有 backend 分组:追加一个完整分组
|
||
$new = rtrim($content) . "\n\n"
|
||
. "Route::group('backend', function () {\n"
|
||
. $block
|
||
. "})->layer('backend');\n";
|
||
return $this->writeWithLint($file, $new, $content);
|
||
}
|
||
|
||
/**
|
||
* 写入 PHP 文件并做语法校验,失败则回滚。
|
||
* 路由文件一旦写坏,整个 think 命令行都会启动失败,故必须兜底。
|
||
*
|
||
* @return bool|string
|
||
*/
|
||
protected function writeWithLint(string $file, string $new, string $original)
|
||
{
|
||
// 沿用文件原有的换行符,避免 CRLF / LF 混用
|
||
$eol = strpos($original, "\r\n") !== false ? "\r\n" : "\n";
|
||
$new = str_replace(["\r\n", "\n"], ["\n", $eol], $new);
|
||
|
||
file_put_contents($file, $new);
|
||
if ($this->lint($file)) {
|
||
return true;
|
||
}
|
||
file_put_contents($file, $original);
|
||
return "写入后语法检查未通过,已回滚:" . $this->short($file);
|
||
}
|
||
|
||
/**
|
||
* 语法检查
|
||
*
|
||
* 环境禁用了 exec/shell_exec,无法调用 `php -l`;改用 token_get_all() 的
|
||
* TOKEN_PARSE 标志——它会对代码做完整语法解析,出错时抛 ParseError。
|
||
*/
|
||
protected function lint(string $file): bool
|
||
{
|
||
$code = (string) file_get_contents($file);
|
||
try {
|
||
token_get_all($code, TOKEN_PARSE);
|
||
return true;
|
||
} catch (\Throwable $e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 向插件 menu.json 追加菜单项与按钮权限节点
|
||
*
|
||
* @return bool|string
|
||
*/
|
||
protected function appendMenu(string $file, array $ctx)
|
||
{
|
||
$menu = is_file($file) ? json_decode((string) file_get_contents($file), true) : null;
|
||
if (!is_array($menu)) {
|
||
$menu = ['frontend' => [], 'member' => [], 'backend' => []];
|
||
}
|
||
if (!isset($menu['backend']) || !is_array($menu['backend'])) {
|
||
$menu['backend'] = [];
|
||
}
|
||
|
||
$name = $ctx['route'] . '/index';
|
||
foreach ($menu['backend'] as $item) {
|
||
if (($item['name'] ?? '') === $name) {
|
||
return "菜单项 {$name} 已存在,未重复写入";
|
||
}
|
||
}
|
||
|
||
$menu['backend'][] = [
|
||
'name' => $name,
|
||
'title' => $ctx['title'],
|
||
'icon' => $ctx['icon'],
|
||
'type' => 2,
|
||
'sort' => 100 + count($menu['backend']) * 10,
|
||
'route' => $ctx['addon'] . '/backend/' . $ctx['route'] . '/index',
|
||
'child' => [
|
||
['name' => $ctx['route'] . ':add', 'title' => '新增', 'type' => 4],
|
||
['name' => $ctx['route'] . ':update', 'title' => '编辑', 'type' => 4],
|
||
['name' => $ctx['route'] . ':delete', 'title' => '删除', 'type' => 4],
|
||
],
|
||
];
|
||
|
||
file_put_contents(
|
||
$file,
|
||
json_encode($menu, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||
);
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 核心后台:把菜单与按钮权限直接写入 backend_power 表
|
||
*
|
||
* 核心后台没有 menu.json,菜单即权限记录(type:1目录 / 2菜单 / 3按钮),
|
||
* 权限名格式为 controller:action,与 Auth::verifyAuth 的精确匹配一致。
|
||
*
|
||
* @return bool|string
|
||
*/
|
||
protected function appendPower(array $ctx, int $pid)
|
||
{
|
||
$name = $ctx['perm'] . ':index';
|
||
try {
|
||
$exist = Db::name('backend_power')->where('name', $name)->find();
|
||
} catch (\Throwable $e) {
|
||
return '权限表不可用,已跳过(' . $e->getMessage() . ')';
|
||
}
|
||
if ($exist) {
|
||
return "权限节点 {$name} 已存在,未重复写入";
|
||
}
|
||
|
||
$now = time();
|
||
try {
|
||
$menuId = Db::name('backend_power')->insertGetId([
|
||
'pid' => max(0, $pid),
|
||
'title' => $ctx['title'],
|
||
'name' => $name,
|
||
'type' => 2,
|
||
'route' => $ctx['route'] . '/index',
|
||
'icon' => $ctx['icon'],
|
||
'sort' => 100,
|
||
'addon' => null,
|
||
'status' => 1,
|
||
'create_at' => $now,
|
||
'update_at' => $now,
|
||
'delete_at' => 0,
|
||
]);
|
||
foreach (['add' => '新增', 'update' => '编辑', 'delete' => '删除'] as $act => $label) {
|
||
Db::name('backend_power')->insert([
|
||
'pid' => $menuId,
|
||
'title' => $label,
|
||
'name' => $ctx['perm'] . ':' . $act,
|
||
'type' => 3,
|
||
'route' => $ctx['route'] . '/' . $act,
|
||
'icon' => '',
|
||
'sort' => 1,
|
||
'addon' => null,
|
||
'status' => 1,
|
||
'create_at' => $now,
|
||
'update_at' => $now,
|
||
'delete_at' => 0,
|
||
]);
|
||
}
|
||
} catch (\Throwable $e) {
|
||
return '写入权限节点失败:' . $e->getMessage();
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/* ============================ 表结构解析 ============================ */
|
||
|
||
protected function normalizeColumns(array $columns): array
|
||
{
|
||
$fields = [];
|
||
foreach ($columns as $col) {
|
||
$col = array_change_key_case($col, CASE_LOWER);
|
||
$type = $col['type'] ?? 'varchar(255)';
|
||
[$base, $length, $options] = $this->parseType($type);
|
||
$fields[] = [
|
||
'field' => $col['field'] ?? '',
|
||
'type' => $type,
|
||
'base' => $base,
|
||
'length' => $length,
|
||
'options' => $options,
|
||
'null' => strtoupper((string) ($col['null'] ?? '')) === 'YES',
|
||
'key' => (string) ($col['key'] ?? ''),
|
||
'default' => $col['default'] ?? null,
|
||
'extra' => strtolower((string) ($col['extra'] ?? '')),
|
||
'comment' => trim((string) ($col['comment'] ?? '')),
|
||
'required' => false,
|
||
];
|
||
}
|
||
// 必填判定:NOT NULL + 无默认值 + 非自增 + 非时间戳 + 非主键
|
||
foreach ($fields as $i => $f) {
|
||
$fields[$i]['required'] = !$f['null']
|
||
&& $f['default'] === null
|
||
&& $f['extra'] !== 'auto_increment'
|
||
&& $f['key'] !== 'PRI'
|
||
&& !in_array($f['field'], self::TIME_FIELDS, true);
|
||
}
|
||
return $fields;
|
||
}
|
||
|
||
/**
|
||
* 解析字段类型:varchar(50) → ['varchar', 50, []];enum('a','b') → ['enum', 0, ['a','b']]
|
||
*/
|
||
protected function parseType(string $type): array
|
||
{
|
||
$type = strtolower(trim($type));
|
||
$base = $type;
|
||
$len = 0;
|
||
$opts = [];
|
||
if (preg_match('/^([a-z]+)\s*(\((.*)\))?/', $type, $m)) {
|
||
$base = $m[1];
|
||
if (isset($m[3]) && $m[3] !== '') {
|
||
$inner = $m[3];
|
||
if (in_array($base, ['enum', 'set'], true)) {
|
||
$opts = array_map(fn($v) => trim($v, " '\""), explode(',', $inner));
|
||
} elseif (strpos($inner, ',') !== false) {
|
||
$len = (int) trim(explode(',', $inner)[0]);
|
||
} else {
|
||
$len = (int) $inner;
|
||
}
|
||
}
|
||
}
|
||
return [$base, $len, $opts];
|
||
}
|
||
|
||
protected function uniqueFields(string $table): array
|
||
{
|
||
try {
|
||
$indexes = Db::query('SHOW INDEX FROM `' . $table . '`');
|
||
} catch (\Throwable $e) {
|
||
return [];
|
||
}
|
||
$single = [];
|
||
$multi = [];
|
||
foreach ($indexes as $idx) {
|
||
$idx = array_change_key_case($idx, CASE_LOWER);
|
||
if ((int) ($idx['non_unique'] ?? 1) !== 0 || ($idx['key_name'] ?? '') === 'PRIMARY') {
|
||
continue;
|
||
}
|
||
$single[$idx['key_name']][] = $idx['column_name'] ?? '';
|
||
}
|
||
foreach ($single as $cols) {
|
||
if (count($cols) === 1) {
|
||
$multi[] = $cols[0];
|
||
}
|
||
}
|
||
return $multi;
|
||
}
|
||
|
||
protected function tableComment(string $table): string
|
||
{
|
||
try {
|
||
$row = Db::query('SHOW TABLE STATUS WHERE `Name` = \'' . $table . '\'');
|
||
} catch (\Throwable $e) {
|
||
return '';
|
||
}
|
||
if (empty($row)) {
|
||
return '';
|
||
}
|
||
$row = array_change_key_case($row[0], CASE_LOWER);
|
||
return trim((string) ($row['comment'] ?? ''));
|
||
}
|
||
|
||
protected function primaryKey(array $fields): string
|
||
{
|
||
foreach ($fields as $f) {
|
||
if ($f['key'] === 'PRI') {
|
||
return $f['field'];
|
||
}
|
||
}
|
||
return 'id';
|
||
}
|
||
|
||
/**
|
||
* 可编辑字段:排除主键与时间戳字段
|
||
*/
|
||
protected function editableFields(array $fields): array
|
||
{
|
||
$out = [];
|
||
foreach ($fields as $f) {
|
||
if ($f['key'] === 'PRI' || in_array($f['field'], self::TIME_FIELDS, true)) {
|
||
continue;
|
||
}
|
||
$out[] = $f;
|
||
}
|
||
return $out;
|
||
}
|
||
|
||
protected function tablePrefix(): string
|
||
{
|
||
$prefix = (string) \think\facade\Config::get('database.connections.mysql.prefix', '');
|
||
if ($prefix === '') {
|
||
$prefix = (string) Db::getConfig('prefix');
|
||
}
|
||
return $prefix;
|
||
}
|
||
|
||
/* ============================== 工具 ============================== */
|
||
|
||
protected function stub(string $name): string
|
||
{
|
||
return (string) file_get_contents(__DIR__ . DIRECTORY_SEPARATOR . 'crud' . DIRECTORY_SEPARATOR . $name);
|
||
}
|
||
|
||
/**
|
||
* 收敛占位符为空时产生的连续空行
|
||
*/
|
||
protected function clean(string $content): string
|
||
{
|
||
return (string) preg_replace("/\n{3,}/", "\n\n", $content);
|
||
}
|
||
|
||
protected function short(string $path): string
|
||
{
|
||
return str_replace($this->app->getRootPath(), '', $path);
|
||
}
|
||
|
||
protected function studly(string $value): string
|
||
{
|
||
return str_replace(' ', '', ucwords(str_replace(['_', '-'], ' ', $value)));
|
||
}
|
||
|
||
protected function camel(string $value): string
|
||
{
|
||
return lcfirst($this->studly($value));
|
||
}
|
||
|
||
protected function snake(string $value): string
|
||
{
|
||
return strtolower((string) preg_replace('/(?<!^)[A-Z]/', '_$0', $value));
|
||
}
|
||
|
||
/**
|
||
* 字段名 → 中文标签(仅在表无注释时的兜底)
|
||
*/
|
||
protected function labelOf(string $field): string
|
||
{
|
||
$map = [
|
||
'name' => '名称', 'title' => '标题', 'status' => '状态', 'sort' => '排序',
|
||
'remark' => '备注', 'content' => '内容', 'description' => '描述', 'intro' => '简介',
|
||
'cover' => '封面', 'image' => '图片', 'avatar' => '头像', 'url' => '链接',
|
||
'type' => '类型', 'pid' => '上级', 'weight' => '权重', 'mobile' => '手机号',
|
||
'email' => '邮箱', 'username' => '用户名', 'alias' => '别名', 'price' => '价格',
|
||
];
|
||
return $map[$field] ?? ucfirst(str_replace('_', ' ', $field));
|
||
}
|
||
}
|