Files

480 lines
18 KiB
PHP
Raw Permalink 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.
<?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\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', "<?php" . PHP_EOL . "// 插件公共文件" . PHP_EOL);
}
// 示例前台控制器 Index
$this->generate('controller', ['name' => 'Index', 'layer' => 'frontend', 'kind' => 'default']);
// route/app.php
if (!is_file($this->addonDir . 'route/app.php')) {
$this->writeFile('route/app.php', "<?php" . PHP_EOL . "use think\\facade\\Route;" . PHP_EOL . PHP_EOL);
}
// menu.json
if (!is_file($this->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', "<?php" . PHP_EOL . "// 插件配置项(后台「配置」表单数据源)" . PHP_EOL . "return [];" . PHP_EOL);
}
// 静态资源目录
$pub = public_path() . 'static/' . $name . DIRECTORY_SEPARATOR;
foreach (['', 'img', 'css', 'js'] as $d) {
$this->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 = ["<?php", "use think\\facade\\Route;", ""];
foreach ($routes as $r) {
if (!is_array($r)) {
continue;
}
$method = strtolower($r['method'] ?? 'get');
$path = $r['path'] ?? '';
$ctrl = $r['controller'] ?? '';
$action = $r['action'] ?? 'index';
if ($path === '' || $ctrl === '') {
continue;
}
if (!in_array($method, ['get', 'post', 'put', 'delete', 'patch', 'any', 'resource'], true)) {
$method = 'get';
}
$lines[] = "Route::{$method}('{$path}', '{$ctrl}/{$action}');";
}
file_put_contents($this->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 = "<?php\n\nreturn " . $this->exportArray($info) . ";\n";
file_put_contents($this->addonDir . 'info.php', $string);
}
/**
* 写回返回数组的 PHP 文件(config.php 等)
*/
private function writeReturnArray(string $file, array $data): void
{
$string = "<?php\n\nreturn " . $this->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);
}
}