- plugin.json 新增 usage 字段(多行文本),后台插件卡片折叠展示;四个内置插件补齐使用说明 - PageCacheMiddleware 记录命中/未命中计数(按日滚动),仪表盘新增「页面缓存」统计卡(条目数 + 命中率),用于判断前台缓存效果、是否需额外加速 - 文档与测试同步
262 lines
7.9 KiB
PHP
262 lines
7.9 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
|
||
namespace App\Blog\Support;
|
||
|
||
use App\Models\PluginRecord;
|
||
use Illuminate\Support\Facades\File;
|
||
use Illuminate\Support\Str;
|
||
use RuntimeException;
|
||
|
||
class PluginManager
|
||
{
|
||
/** @var array<string, array<int, callable>> */
|
||
private array $actions = [];
|
||
|
||
/** @var array<string, array<int, callable>> */
|
||
private array $filters = [];
|
||
|
||
/** @var array<string, class-string<\App\Blog\Support\PluginServiceProvider>> */
|
||
private array $providers = [];
|
||
|
||
public function path(?string $plugin = null): string
|
||
{
|
||
return config('plugins.path').($plugin ? '/'.$plugin : '');
|
||
}
|
||
|
||
public function exists(string $plugin): bool
|
||
{
|
||
return is_dir($this->path($plugin)) && File::exists($this->path($plugin).'/plugin.json');
|
||
}
|
||
|
||
public function manifest(string $plugin): array
|
||
{
|
||
$file = $this->path($plugin).'/plugin.json';
|
||
|
||
if (! File::exists($file)) {
|
||
return [];
|
||
}
|
||
|
||
return json_decode(File::get($file), true) ?? [];
|
||
}
|
||
|
||
public function all(): array
|
||
{
|
||
$plugins = [];
|
||
|
||
foreach (File::directories(config('plugins.path')) as $dir) {
|
||
$basename = basename($dir);
|
||
$manifest = $this->manifest($basename);
|
||
if (! $manifest) {
|
||
continue;
|
||
}
|
||
|
||
[$vendor, $name] = array_pad(explode('.', $basename), 2, $basename);
|
||
|
||
$requires = $manifest['requires'] ?? [];
|
||
$dependencies = [];
|
||
$dependencyErrors = [];
|
||
|
||
foreach ($requires as $req) {
|
||
$installed = $this->exists($req);
|
||
$enabled = $installed && $this->isEnabled($req);
|
||
$dependencies[] = [
|
||
'key' => $req,
|
||
'title' => $this->manifest($req)['title'] ?? $req,
|
||
'ok' => $enabled,
|
||
'reason' => $installed ? ($enabled ? '' : '未启用') : '未安装',
|
||
];
|
||
|
||
if (! $enabled) {
|
||
$dependencyErrors[] = $req;
|
||
}
|
||
}
|
||
|
||
$plugins[$basename] = [
|
||
'key' => $basename,
|
||
'vendor' => $vendor,
|
||
'name' => $name,
|
||
'title' => $manifest['title'] ?? $name,
|
||
'version' => $manifest['version'] ?? '0.0.0',
|
||
'description' => $manifest['description'] ?? '',
|
||
'usage' => $manifest['usage'] ?? '',
|
||
'author' => $manifest['author'] ?? '',
|
||
'enabled' => $this->isEnabled($basename),
|
||
'requires' => $requires,
|
||
'dependencies' => $dependencies,
|
||
'dependency_errors' => $dependencyErrors,
|
||
];
|
||
}
|
||
|
||
return $plugins;
|
||
}
|
||
|
||
public function isEnabled(string $plugin): bool
|
||
{
|
||
if (\Illuminate\Support\Facades\Schema::hasTable('plugin_records')) {
|
||
[$vendor, $name] = array_pad(explode('.', $plugin), 2, $plugin);
|
||
|
||
$record = PluginRecord::query()->where('vendor', $vendor)->where('name', $name)->first();
|
||
|
||
if ($record) {
|
||
return (bool) $record->enabled;
|
||
}
|
||
}
|
||
|
||
return in_array($plugin, config('plugins.enabled', []), true);
|
||
}
|
||
|
||
public function requires(string $plugin): array
|
||
{
|
||
return $this->manifest($plugin)['requires'] ?? [];
|
||
}
|
||
|
||
/**
|
||
* 依赖未满足的问题列表(空数组 = 依赖完好)。
|
||
*/
|
||
public function dependencyProblems(string $plugin): array
|
||
{
|
||
$problems = [];
|
||
|
||
foreach ($this->requires($plugin) as $req) {
|
||
if (! $this->exists($req)) {
|
||
$problems[] = "缺少依赖插件:{$req}(未安装)";
|
||
} elseif (! $this->isEnabled($req)) {
|
||
$problems[] = '依赖插件未启用:'.$req.'('.$this->manifest($req)['title'].')';
|
||
}
|
||
}
|
||
|
||
return $problems;
|
||
}
|
||
|
||
/**
|
||
* 已启用且依赖本插件的插件列表。
|
||
*/
|
||
public function dependents(string $plugin): array
|
||
{
|
||
$dependents = [];
|
||
|
||
foreach ($this->all() as $key => $p) {
|
||
if ($p['enabled'] && in_array($plugin, $p['requires'] ?? [], true)) {
|
||
$dependents[] = $key;
|
||
}
|
||
}
|
||
|
||
return $dependents;
|
||
}
|
||
|
||
public function enable(string $plugin): void
|
||
{
|
||
$problems = $this->dependencyProblems($plugin);
|
||
|
||
if ($problems) {
|
||
throw new RuntimeException('无法启用 '.$plugin.':'.implode(';', $problems));
|
||
}
|
||
|
||
[$vendor, $name] = array_pad(explode('.', $plugin), 2, $plugin);
|
||
PluginRecord::query()->updateOrCreate(
|
||
['vendor' => $vendor, 'name' => $name],
|
||
['enabled' => true]
|
||
);
|
||
}
|
||
|
||
public function disable(string $plugin): void
|
||
{
|
||
$dependents = $this->dependents($plugin);
|
||
|
||
if ($dependents) {
|
||
throw new RuntimeException('无法停用 '.$plugin.':以下已启用插件依赖它:'.implode('、', $dependents).'。请先停用这些插件。');
|
||
}
|
||
|
||
[$vendor, $name] = array_pad(explode('.', $plugin), 2, $plugin);
|
||
PluginRecord::query()->updateOrCreate(
|
||
['vendor' => $vendor, 'name' => $name],
|
||
['enabled' => false]
|
||
);
|
||
}
|
||
|
||
public function uninstall(string $plugin): void
|
||
{
|
||
$dependents = $this->dependents($plugin);
|
||
|
||
if ($dependents) {
|
||
throw new RuntimeException('无法卸载 '.$plugin.':以下已启用插件依赖它:'.implode('、', $dependents).'。请先停用这些插件。');
|
||
}
|
||
|
||
[$vendor, $name] = array_pad(explode('.', $plugin), 2, $plugin);
|
||
PluginRecord::query()->where('vendor', $vendor)->where('name', $name)->delete();
|
||
}
|
||
|
||
/**
|
||
* 解析插件提供者类:plugins/{vendor}.{name}/src/PluginServiceProvider.php
|
||
*
|
||
* @return class-string<\App\Blog\Support\PluginServiceProvider>|null
|
||
*/
|
||
public function resolveProvider(string $plugin): ?string
|
||
{
|
||
$class = $this->providers[$plugin] ?? null;
|
||
|
||
if ($class) {
|
||
return $class;
|
||
}
|
||
|
||
// 类名兜底:先找 src/ServiceProvider.php,再找 src/PluginServiceProvider.php
|
||
$manifest = $this->manifest($plugin);
|
||
$class = $manifest['provider'] ?? null;
|
||
|
||
if (! $class) {
|
||
$candidates = ['/src/ServiceProvider.php', '/src/PluginServiceProvider.php'];
|
||
foreach ($candidates as $candidate) {
|
||
if (File::exists($this->path($plugin).$candidate)) {
|
||
[$vendor, $name] = array_pad(explode('.', $plugin), 2, $plugin);
|
||
$class = 'Plugins\\'.Str::studly($vendor).'\\'.Str::studly($name).'\\'.basename($candidate, '.php');
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
return $this->providers[$plugin] = $class;
|
||
}
|
||
|
||
public function registerProvider(string $plugin, string $class): void
|
||
{
|
||
$this->providers[$plugin] = $class;
|
||
}
|
||
|
||
/* ---------------- Hooks ---------------- */
|
||
|
||
public function addAction(string $hook, callable $callback, int $priority = 10): void
|
||
{
|
||
$this->actions[$hook][$priority][] = $callback;
|
||
ksort($this->actions[$hook]);
|
||
}
|
||
|
||
public function addFilter(string $hook, callable $callback, int $priority = 10): void
|
||
{
|
||
$this->filters[$hook][$priority][] = $callback;
|
||
ksort($this->filters[$hook]);
|
||
}
|
||
|
||
public function doAction(string $hook, mixed ...$args): void
|
||
{
|
||
foreach ($this->actions[$hook] ?? [] as $callbacks) {
|
||
foreach ($callbacks as $callback) {
|
||
$callback(...$args);
|
||
}
|
||
}
|
||
}
|
||
|
||
public function applyFilters(string $hook, mixed $value, mixed ...$args): mixed
|
||
{
|
||
foreach ($this->filters[$hook] ?? [] as $callbacks) {
|
||
foreach ($callbacks as $callback) {
|
||
$value = $callback($value, ...$args);
|
||
}
|
||
}
|
||
|
||
return $value;
|
||
}
|
||
}
|