chore: 重写初始提交(清空历史,整理后全量提交)

This commit is contained in:
ywxapp
2026-08-16 16:54:14 +08:00
commit 6c1a106bc1
1808 changed files with 238144 additions and 0 deletions
+479
View File
@@ -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);
}
}