chore: 重写初始提交(清空历史,整理后全量提交)
This commit is contained in:
@@ -0,0 +1,479 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user