// +---------------------------------------------------------------------- declare(strict_types=1); namespace ywxapp\service; use think\facade\Config; use think\facade\Env; /** * 插件开发模式服务(抄 Discuz! 插件设计器) * * 在「开发模式」开启时,为后台「设计插件」提供可视化创建/编辑能力: * - 创建插件骨架(目录 + info.php/Addon.php/config.php/menu.json/route/app.php/示例控制器) * - 生成各类源码文件(controller/model/event/listener/middleware/service/subscribe/validate/command),复用 ywxapp/command/addon 模板 * - 写回 info.php(基础信息 / events / middleware / services)、config.php、menu.json、route/app.php * - 开发模式安装(原地建表/注入菜单/启用,免打包) * * 框架以文件扫描驱动插件加载,因此上述写回后前台实时生效,无需重新打包安装。 * * @package ywxapp\service */ class AddonDevService { /** @var string 插件标识 */ private string $addon; /** @var string 插件目录绝对路径 */ private string $addonDir; public function __construct(string $name) { $this->addon = $name; $this->addonDir = ADDON_PATH . $name . DIRECTORY_SEPARATOR; } /** * 开发模式是否开启(独立于 APP_DEBUG 的独立开关,抄 DZ 的 plugin['developer']) */ public static function enabled(): bool { if (!defined('ADDON_PATH')) { return false; } return (bool) Env::get('addon_developer', false) || (bool) config('ywxapp.addon_developer', false); } /** * 创建插件骨架 * @param array $meta 基础信息:title/intro/author/website/version/url/license * @return array 生成的 info * @throws \Exception */ public function createSkeleton(array $meta): array { $name = $this->addon; if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $name)) { throw new \Exception('插件标识不合法(字母开头,仅含字母/数字/下划线)'); } if (is_dir($this->addonDir)) { throw new \Exception('插件目录已存在:' . $name); } $this->checkDirBuild($this->addonDir); foreach ([ 'controller', 'controller/backend', 'controller/member', 'model', 'view', 'view/frontend', 'view/backend', 'view/member', 'lang', 'route', 'validate', 'event', 'listener', 'middleware', 'service', 'subscribe', 'command', ] as $d) { $this->checkDirBuild($this->addonDir . $d); } // info.php(直接构造,保证字段完整) $info = [ 'name' => $name, 'title' => $meta['title'] ?? $name, 'intro' => $meta['intro'] ?? '', 'author' => $meta['author'] ?? '', 'website' => $meta['website'] ?? '', 'version' => $meta['version'] ?? '1.0.0', 'state' => 1, 'url' => $meta['url'] ?? '/' . $name, 'license' => $meta['license'] ?? '', 'licenseto' => 0, 'config' => [], 'events' => ['bind' => [], 'listen' => [], 'subscribe' => []], 'middleware' => ['alias' => [], 'priority' => []], 'services' => [], 'install_time' => time(), ]; $this->writeInfo($info); // Addon.php(主类) $this->writeFile('Addon.php', $this->renderStub('addon.stub', [ '{%namespace%}' => 'addon\\' . $name, '{%addon%}' => $name, '{%className%}' => 'Addon', ])); // common.php if (!is_file($this->addonDir . 'common.php')) { $this->writeFile('common.php', "generate('controller', ['name' => 'Index', 'layer' => 'frontend', 'kind' => 'default']); // route/app.php if (!is_file($this->addonDir . 'route/app.php')) { $this->writeFile('route/app.php', "addonDir . 'menu.json')) { $menu = ['frontend' => [], 'member' => [], 'backend' => []]; file_put_contents($this->addonDir . 'menu.json', json_encode($menu, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); } // config.php if (!is_file($this->addonDir . 'config.php')) { $this->writeFile('config.php', "checkDirBuild($pub . $d); } return $info; } /** * 读取插件现有数据(供设计器回显) */ public function readAll(): array { $infoFile = $this->addonDir . 'info.php'; $info = is_file($infoFile) ? (array) include $infoFile : []; $config = is_file($this->addonDir . 'config.php') ? (array) include $this->addonDir . 'config.php' : []; $menu = is_file($this->addonDir . 'menu.json') ? (array) json_decode(file_get_contents($this->addonDir . 'menu.json'), true) : ['frontend' => [], 'member' => [], 'backend' => []]; $routeRaw = ''; $routeFile = $this->addonDir . 'route/app.php'; if (is_file($routeFile)) { $routeRaw = preg_replace('/^<\?php\s*use\s+think\\\\facade\\\\Route;\s*/', '', file_get_contents($routeFile)); $routeRaw = trim($routeRaw); } return [ 'name' => $this->addon, 'info' => $info, 'config' => $config, 'menu' => $menu, 'route_raw' => $routeRaw, ]; } /** * 写回 info.php 基础字段 */ public function saveBasic(array $data): void { $infoFile = $this->addonDir . 'info.php'; if (!is_file($infoFile)) { throw new \Exception('info.php 不存在,请先创建插件'); } $info = (array) include $infoFile; $fields = ['title', 'intro', 'author', 'website', 'version', 'url', 'license', 'licenseto']; foreach ($fields as $f) { if (array_key_exists($f, $data)) { $info[$f] = $data[$f]; } } $this->writeInfo($info); } /** * 写回 config.php(配置项/变量) * @param array $fields 每项 [name,title,type,value,tip,options?] */ public function saveConfig(array $fields): void { $data = []; foreach ($fields as $f) { if (!is_array($f)) { continue; } $name = $f['name'] ?? ''; if ($name === '') { continue; } $item = [ 'name' => $name, 'title' => $f['title'] ?? $name, 'type' => $f['type'] ?? 'string', 'value' => $f['value'] ?? '', 'tip' => $f['tip'] ?? '', ]; if (isset($f['options']) && is_array($f['options'])) { $item['options'] = $f['options']; } $data[] = $item; } $this->writeReturnArray($this->addonDir . 'config.php', $data); } /** * 写回 menu.json(后台/会员/前台菜单) */ public function saveMenu(array $menu): void { $menu = array_merge(['frontend' => [], 'member' => [], 'backend' => []], $menu); file_put_contents($this->addonDir . 'menu.json', json_encode($menu, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); } /** * 写回 info.php 的 events / middleware / services */ public function saveHooks(array $events, array $middleware, array $services): void { $infoFile = $this->addonDir . 'info.php'; if (!is_file($infoFile)) { throw new \Exception('info.php 不存在,请先创建插件'); } $info = (array) include $infoFile; $info['events'] = $events ?: ['bind' => [], 'listen' => [], 'subscribe' => []]; $info['middleware'] = $middleware ?: ['alias' => [], 'priority' => []]; $info['services'] = $services ?: []; $this->writeInfo($info); } /** * 写回 route/app.php(结构化路由规则) * @param array $routes 每项 [method,path,controller,action] */ public function saveRoute(array $routes): void { $lines = ["addonDir . 'route/app.php', implode("\n", $lines) . "\n"); } /** * 生成源码文件(controller/model/event/listener/middleware/service/subscribe/validate/command) * @param string $type 类型 * @param array $opts name(必填) / layer(frontend|backend|member) / kind(default|api|plain) / command(命令名) * @return string 生成文件路径 * @throws \Exception */ public function generate(string $type, array $opts): string { $name = $opts['name'] ?? ''; if (!$name || !preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $name)) { throw new \Exception('类名不合法(字母开头,仅含字母/数字/下划线)'); } $base = 'addon\\' . $this->addon; switch ($type) { case 'controller': $layer = $opts['layer'] ?? 'frontend'; $kind = $opts['kind'] ?? 'default'; $stubMap = ['api' => 'controller.api.stub', 'plain' => 'controller.plain.stub', 'default' => 'controller.stub']; $stub = $stubMap[$kind] ?? 'controller.stub'; $relDir = $layer === 'backend' ? 'backend/' : ($layer === 'member' ? 'member/' : ''); $ns = $base . '\\controller' . ($relDir ? '\\' . rtrim($relDir, '/') : ''); $baseCls = $layer === 'backend' ? '\ywxapp\controller\BackendBase' : ($layer === 'member' ? '\ywxapp\controller\AddonMember' : '\ywxapp\controller\FrontendBase'); $content = $this->renderStub($stub, [ '{%namespace%}' => $ns, '{%className%}' => $name, '{%actionSuffix%}' => '', '{%app_namespace%}' => $base, ]); $content = str_replace('extends FrontendBase', 'extends ' . $baseCls, $content); $file = $this->addonDir . 'controller/' . $relDir . $name . '.php'; break; case 'model': $content = $this->renderStub('model.stub', ['{%namespace%}' => $base . '\\model', '{%className%}' => $name]); $file = $this->addonDir . 'model/' . $name . '.php'; break; case 'event': $content = $this->renderStub('event.stub', ['{%namespace%}' => $base . '\\event', '{%className%}' => $name]); $file = $this->addonDir . 'event/' . $name . '.php'; break; case 'listener': $content = $this->renderStub('listener.stub', ['{%namespace%}' => $base . '\\listener', '{%className%}' => $name]); $file = $this->addonDir . 'listener/' . $name . '.php'; break; case 'middleware': $content = $this->renderStub('middleware.stub', ['{%namespace%}' => $base . '\\middleware', '{%className%}' => $name]); $file = $this->addonDir . 'middleware/' . $name . '.php'; break; case 'service': $content = $this->renderStub('service.stub', ['{%namespace%}' => $base . '\\service', '{%className%}' => $name]); $file = $this->addonDir . 'service/' . $name . '.php'; break; case 'subscribe': $content = $this->renderStub('subscribe.stub', ['{%namespace%}' => $base . '\\subscribe', '{%className%}' => $name]); $file = $this->addonDir . 'subscribe/' . $name . '.php'; break; case 'validate': $content = $this->renderStub('validate.stub', ['{%namespace%}' => $base . '\\validate', '{%className%}' => $name]); $file = $this->addonDir . 'validate/' . $name . '.php'; break; case 'command': $cmdName = $opts['command'] ?? ($this->addon . ':' . lcfirst($name)); $content = $this->renderStub('command.stub', [ '{%namespace%}' => $base . '\\command', '{%className%}' => $name, '{%commandName%}' => $cmdName, ]); $file = $this->addonDir . 'command/' . $name . '.php'; break; default: throw new \Exception('未知的生成类型:' . $type); } $this->checkDirBuild(dirname($file)); if (is_file($file)) { throw new \Exception('文件已存在:' . ltrim(str_replace($this->addonDir, '', $file), '/')); } file_put_contents($file, $content); return $file; } /** * 开发模式安装(原地建表/注入菜单/启用,免打包) * @return array * @throws \Exception */ public function developInstall(): array { return AddonService::instance($this->addon)->developInstall(); } /** * 删除插件目录(开发调试用) */ public function remove(): bool { if (!is_dir($this->addonDir)) { return true; } return $this->deleteDirectory($this->addonDir); } // ==================== 私有辅助 ==================== /** * 渲染 stub 模板(占位符替换) */ private function renderStub(string $stub, array $replace): string { $file = __DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'command' . DIRECTORY_SEPARATOR . 'addon' . DIRECTORY_SEPARATOR . 'stubs' . DIRECTORY_SEPARATOR . $stub; if (!is_file($file)) { throw new \Exception('stub 模板不存在:' . $stub); } $content = file_get_contents($file); return str_replace(array_keys($replace), array_values($replace), $content); } /** * 写文件(带目录创建) */ private function writeFile(string $relative, string $content): void { $file = $this->addonDir . $relative; $this->checkDirBuild(dirname($file)); file_put_contents($file, $content); } /** * 写回 info.php(短数组风格,递归导出保证 [] 配对正确) */ private function writeInfo(array $info): void { $string = "exportArray($info) . ";\n"; file_put_contents($this->addonDir . 'info.php', $string); } /** * 写回返回数组的 PHP 文件(config.php 等) */ private function writeReturnArray(string $file, array $data): void { $string = "exportArray($data) . ";\n"; file_put_contents($file, $string); } /** * 递归导出为合法的 PHP 短数组语法(保证 [ 与 ] 配对) */ private function exportArray(array $data, int $indent = 0): string { if (empty($data)) { return '[]'; } $isAssoc = array_keys($data) !== range(0, count($data) - 1); $pad = str_repeat(' ', $indent + 1); $padEnd = str_repeat(' ', $indent); $lines = []; foreach ($data as $k => $v) { $key = $isAssoc ? var_export($k, true) . ' => ' : ''; $val = is_array($v) ? $this->exportArray($v, $indent + 1) : var_export($v, true); $lines[] = $pad . $key . $val; } return "[\n" . implode(",\n", $lines) . "\n" . $padEnd . "]"; } /** * 创建目录 */ private function checkDirBuild(string $dirname): void { if (!is_dir($dirname)) { mkdir($dirname, 0755, true); } } /** * 递归删除目录 */ private function deleteDirectory(string $dirPath): bool { if (!is_dir($dirPath)) { return false; } $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($dirPath, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST ); foreach ($iterator as $item) { if ($item->isDir()) { @rmdir($item->getPathname()); } else { @unlink($item->getPathname()); } } return @rmdir($dirPath); } }