// +---------------------------------------------------------------------- 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/.php 模型(表名/时间戳/软删除/类型转换) * 2. addon/<插件>/controller/backend/.php 控制器(index/add/update/delete) * 3. addon/<插件>/view/backend//index.html 列表页 + 弹窗表单模板 * 4. public/static/backend/modules/.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 */ 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('请通过 -t/--table 指定数据表,示例:php think addon:crud articles -t articles_category'); return 1; } $root = $this->app->getRootPath(); if ($mode === 'addon') { if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $addon)) { $output->writeln('插件名不合法,示例:php think addon:crud articles -t articles_category'); return 1; } $addonDir = $root . 'addon' . DIRECTORY_SEPARATOR . $addon . DIRECTORY_SEPARATOR; if (!is_dir($addonDir)) { $output->writeln("插件目录不存在:addon/{$addon},请先执行 php think addon:make {$addon} -a build"); 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('读取表结构失败:' . $fullTable . '(' . $e->getMessage() . ')'); return 1; } if (empty($columns)) { $output->writeln("表不存在或无字段:{$fullTable}"); 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' ? "表 {$fullTable} → 插件 {$addon} / 类 {$class}" : "表 {$fullTable} → 核心后台 / 类 {$class}"); /* ------------------------------ 生成文件 ------------------------------ */ $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('已存在,跳过(加 -f 覆盖):' . $this->short($path) . ''); continue; } if (!is_dir(dirname($path))) { mkdir(dirname($path), 0755, true); } file_put_contents($path, $this->clean($content)); $written++; $output->writeln('生成 ' . $this->short($path) . ''); } /* --------------------------- 路由 / 菜单 --------------------------- */ $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 ? '已追加后台路由到 ' . $routeTip . '' : '' . $res . ''); } if (!$input->getOption('no-menu')) { if ($mode === 'addon') { $res = $this->appendMenu($addonDir . 'menu.json', $ctx); $ok = '已追加菜单与按钮权限到 addon/' . $addon . '/menu.json'; } else { $res = $this->appendPower($ctx, (int) $input->getOption('pid')); $ok = '已写入菜单与按钮权限到 backend_power 表'; } $output->writeln($res === true ? $ok : '' . $res . ''); } $output->writeln(''); $output->writeln("CRUD 生成完成(写入 {$written} 个文件)"); $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 .= '
' . "\n"; $html .= ' ' . "\n"; // 开关/状态:tinyint 且字段名含 status / is_ / enable if ($type === 'tinyint' && (in_array($name, ['status', 'enable', 'enabled'], true) || str_starts_with($name, 'is_'))) { $html .= '
' . "\n"; $html .= ' ' . "\n"; $html .= ' ' . "\n"; $html .= '
' . "\n"; $html .= '
'; return $html; } // 枚举:生成下拉 if (in_array($type, ['enum', 'set'], true) && $f['options'] !== []) { $html .= '
' . "\n"; $html .= ' ' . "\n"; $html .= '
' . "\n"; $html .= ' '; return $html; } // 图片 / 文件字段 if (in_array($name, self::MEDIA_FIELDS, true)) { $html .= '
' . "\n"; $html .= ' ' . "\n"; $html .= '
' . "\n"; $html .= ' '; return $html; } // 大文本 if (in_array($type, ['text', 'tinytext', 'mediumtext', 'longtext'], true) || in_array($name, self::TEXT_FIELDS, true)) { $html .= '
' . "\n"; $html .= ' ' . "\n"; $html .= '
' . "\n"; $html .= ' '; return $html; } // 数字 if (in_array($type, ['int', 'bigint', 'smallint', 'mediumint', 'tinyint', 'decimal', 'float', 'double'], true)) { $html .= '
' . "\n"; $html .= ' ' . "\n"; $html .= '
' . "\n"; $html .= ' '; return $html; } // 日期时间(date/datetime 类型,非 int 时间戳) if (in_array($type, ['date', 'datetime', 'time'], true)) { $html .= '
' . "\n"; $html .= ' ' . "\n"; $html .= '
' . "\n"; $html .= ' '; return $html; } // 默认文本框 $html .= '
' . "\n"; $html .= ' ' . "\n"; $html .= '
' . "\n"; $html .= ' '; 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 '
' . "\n" . '
' . "\n" . ' ' . "\n" . '
' . "\n" . ' ' . "\n" . ' ' . "\n" . '
' . "\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" . '' . "\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('/(? '名称', '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)); } }