From 4f59afb7ce251efd9f28863e0a01c205ca8ff2a5 Mon Sep 17 00:00:00 2001 From: ak Date: Wed, 12 Aug 2026 00:18:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8F=92=E4=BB=B6=E4=BE=9D=E8=B5=96?= =?UTF-8?q?=E5=BC=BA=E5=88=B6=E6=A0=A1=E9=AA=8C=20+=20=E6=96=87=E7=AB=A0?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E8=A1=A8=E5=8D=95/=E8=A1=A8=E6=A0=BC?= =?UTF-8?q?=E6=B3=A8=E5=85=A5=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PluginManager: requires 依赖链校验(enable 需依赖已启用,disable 阻止有启用依赖者的插件,boot 跳过依赖未满足插件),all() 输出依赖状态供后台展示 - Plugins 页:卡片显示依赖链徽章,依赖未满足时禁用启用按钮,启停失败弹错误通知 - PostResource: form()/table() 增加 filament.post_form / filament.post_table 注入点 - membership 插件:注入「付费设置」Section(meta.price 单篇价格 + meta.members_only 会员专享)与表格价格/会员列 --- .../PluginManagerServiceProvider.php | 5 + app/Blog/Support/PluginManager.php | 74 +++++++++++++ app/Filament/Pages/Plugins.php | 16 ++- app/Filament/Resources/Posts/PostResource.php | 14 ++- .../src/ServiceProvider.php | 41 +++++++ .../views/filament/pages/plugins.blade.php | 15 ++- tests/Feature/PluginDependencyTest.php | 101 ++++++++++++++++++ tests/Feature/PostFormInjectionTest.php | 70 ++++++++++++ 8 files changed, 329 insertions(+), 7 deletions(-) create mode 100644 tests/Feature/PluginDependencyTest.php create mode 100644 tests/Feature/PostFormInjectionTest.php diff --git a/app/Blog/Providers/PluginManagerServiceProvider.php b/app/Blog/Providers/PluginManagerServiceProvider.php index 8f24f68..a100220 100644 --- a/app/Blog/Providers/PluginManagerServiceProvider.php +++ b/app/Blog/Providers/PluginManagerServiceProvider.php @@ -28,6 +28,11 @@ class PluginManagerServiceProvider extends ServiceProvider continue; } + // 依赖未满足的插件跳过加载,避免运行时缺底层能力 + if ($manager->dependencyProblems($key)) { + continue; + } + $provider = $manager->resolveProvider($key); if ($provider && class_exists($provider)) { diff --git a/app/Blog/Support/PluginManager.php b/app/Blog/Support/PluginManager.php index 3e4427d..d35321e 100644 --- a/app/Blog/Support/PluginManager.php +++ b/app/Blog/Support/PluginManager.php @@ -8,6 +8,7 @@ namespace App\Blog\Support; use App\Models\PluginRecord; use Illuminate\Support\Facades\File; use Illuminate\Support\Str; +use RuntimeException; class PluginManager { @@ -54,6 +55,25 @@ class PluginManager [$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, @@ -63,6 +83,9 @@ class PluginManager 'description' => $manifest['description'] ?? '', 'author' => $manifest['author'] ?? '', 'enabled' => $this->isEnabled($basename), + 'requires' => $requires, + 'dependencies' => $dependencies, + 'dependency_errors' => $dependencyErrors, ]; } @@ -84,8 +107,53 @@ class PluginManager 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], @@ -95,6 +163,12 @@ class PluginManager 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], diff --git a/app/Filament/Pages/Plugins.php b/app/Filament/Pages/Plugins.php index 61b1226..24fff39 100644 --- a/app/Filament/Pages/Plugins.php +++ b/app/Filament/Pages/Plugins.php @@ -38,14 +38,22 @@ class Plugins extends Page public function enablePlugin(string $key): void { - app(PluginManager::class)->enable($key); - Notification::make()->title('已启用:'.$key)->success()->send(); + try { + app(PluginManager::class)->enable($key); + Notification::make()->title('已启用:'.$key)->success()->send(); + } catch (\Throwable $e) { + Notification::make()->title('启用失败')->body($e->getMessage())->danger()->send(); + } } public function disablePlugin(string $key): void { - app(PluginManager::class)->disable($key); - Notification::make()->title('已停用:'.$key)->warning()->send(); + try { + app(PluginManager::class)->disable($key); + Notification::make()->title('已停用:'.$key)->warning()->send(); + } catch (\Throwable $e) { + Notification::make()->title('停用失败')->body($e->getMessage())->danger()->send(); + } } public function uninstallPlugin(string $key): void diff --git a/app/Filament/Resources/Posts/PostResource.php b/app/Filament/Resources/Posts/PostResource.php index 2427156..bc0b9e8 100644 --- a/app/Filament/Resources/Posts/PostResource.php +++ b/app/Filament/Resources/Posts/PostResource.php @@ -33,12 +33,22 @@ class PostResource extends Resource public static function form(Schema $schema): Schema { - return PostForm::configure($schema); + $schema = PostForm::configure($schema); + + // 插件注入:会员付费设置、AI 选项等后台扩展 + app(\App\Blog\Support\PluginManager::class)->doAction('filament.post_form', $schema); + + return $schema; } public static function table(Table $table): Table { - return PostsTable::configure($table); + $table = PostsTable::configure($table); + + // 插件注入:价格、会员专享等表格列 + app(\App\Blog\Support\PluginManager::class)->doAction('filament.post_table', $table); + + return $table; } public static function getRelations(): array diff --git a/plugins/neatstudio.membership/src/ServiceProvider.php b/plugins/neatstudio.membership/src/ServiceProvider.php index d8d2d21..39c6c6b 100644 --- a/plugins/neatstudio.membership/src/ServiceProvider.php +++ b/plugins/neatstudio.membership/src/ServiceProvider.php @@ -7,6 +7,13 @@ namespace Plugins\Neatstudio\Membership; use App\Blog\Support\PluginManager; use App\Blog\Support\PluginServiceProvider; +use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\Toggle; +use Filament\Schemas\Components\Section; +use Filament\Schemas\Schema; +use Filament\Tables\Columns\IconColumn; +use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Table; use Illuminate\Support\Str; use Plugins\Neatstudio\Membership\Models\MembershipPlan; use Plugins\Neatstudio\Membership\Models\Subscription; @@ -75,5 +82,39 @@ class ServiceProvider extends PluginServiceProvider return $html; }, 10); + + // 后台文章表单注入:付费设置(meta.price 单篇价格 + meta.members_only 会员专享) + $manager->addAction('filament.post_form', function (Schema $schema) { + $schema->components([ + ...$schema->getComponents(), + Section::make('付费设置') + ->description('需启用支付插件并配置支付渠道;0 = 免费') + ->columns(3) + ->collapsible() + ->schema([ + TextInput::make('meta.price') + ->label('单篇价格(分)') + ->numeric() + ->minValue(0) + ->default(0) + ->helperText('例:9900 = ¥99;填 0 表示单篇免费'), + Toggle::make('meta.members_only') + ->label('会员专享') + ->helperText('整篇文章仅会员可读'), + ]), + ]); + }); + + // 后台文章表格注入:价格 / 会员列 + $manager->addAction('filament.post_table', function (Table $table) { + $table->pushColumns([ + TextColumn::make('meta.price') + ->label('单篇价格') + ->formatStateUsing(fn ($state) => $state > 0 ? '¥'.number_format((float) $state / 100, 2) : '—'), + IconColumn::make('meta.members_only') + ->label('会员') + ->boolean(), + ]); + }); } } diff --git a/resources/views/filament/pages/plugins.blade.php b/resources/views/filament/pages/plugins.blade.php index 2d1562e..6806678 100644 --- a/resources/views/filament/pages/plugins.blade.php +++ b/resources/views/filament/pages/plugins.blade.php @@ -17,11 +17,24 @@

{{ $plugin['description'] }}

v{{ $plugin['version'] }} · {{ $plugin['vendor'] }}.{{ $plugin['name'] }}

+ @if(! empty($plugin['dependencies'])) +
+ 依赖: + @foreach($plugin['dependencies'] as $dep) + + {{ $dep['title'] }}@if(! $dep['ok'])({{ $dep['reason'] }})@endif + + @endforeach +
+ @endif +
@if($plugin['enabled']) 停用 - @else + @elseif(empty($plugin['dependency_errors'])) 启用 + @else + 依赖未满足,无法启用 @endif @if(! in_array($plugin['key'], config('plugins.enabled', []), true)) 卸载 diff --git a/tests/Feature/PluginDependencyTest.php b/tests/Feature/PluginDependencyTest.php new file mode 100644 index 0000000..9903f2b --- /dev/null +++ b/tests/Feature/PluginDependencyTest.php @@ -0,0 +1,101 @@ +manager(); + + // 先停用无依赖者的 membership,再停用 payment(此时无启用的依赖者) + $manager->disable('neatstudio.membership'); + $manager->disable('neatstudio.payment'); + + try { + $manager->enable('neatstudio.membership'); + $this->fail('依赖未启用时应抛出异常'); + } catch (RuntimeException $e) { + $this->assertStringContainsString('neatstudio.payment', $e->getMessage()); + } + + $this->assertFalse($manager->isEnabled('neatstudio.membership')); + } + + public function test_disable_plugin_with_enabled_dependent_throws(): void + { + $manager = $this->manager(); + + // 默认状态下 payment 已启用且 membership 依赖它 + try { + $manager->disable('neatstudio.payment'); + $this->fail('存在已启用的依赖者时应抛出异常'); + } catch (RuntimeException $e) { + $this->assertStringContainsString('neatstudio.membership', $e->getMessage()); + } + + $this->assertTrue($manager->isEnabled('neatstudio.payment')); + } + + public function test_disable_plugin_without_dependents_works(): void + { + $manager = $this->manager(); + + $manager->disable('neatstudio.membership'); + $this->assertFalse($manager->isEnabled('neatstudio.membership')); + + // 依赖(payment)仍启用,可重新启用 + $manager->enable('neatstudio.membership'); + $this->assertTrue($manager->isEnabled('neatstudio.membership')); + } + + public function test_all_reports_dependency_status(): void + { + $manager = $this->manager(); + + $plugins = $manager->all(); + $member = $plugins['neatstudio.membership']; + + $this->assertSame(['neatstudio.payment'], $member['requires']); + $this->assertTrue($member['dependencies'][0]['ok']); + $this->assertEmpty($member['dependency_errors']); + + // 停用依赖后,依赖状态应变为失败 + $manager->disable('neatstudio.membership'); + $manager->disable('neatstudio.payment'); + + $plugins = $manager->all(); + $this->assertFalse($plugins['neatstudio.membership']['dependencies'][0]['ok']); + $this->assertSame(['neatstudio.payment'], $plugins['neatstudio.membership']['dependency_errors']); + } + + public function test_dependency_problems_for_missing_plugin(): void + { + $manager = $this->manager(); + + // 默认依赖完好 + $this->assertSame([], $manager->dependencyProblems('neatstudio.membership')); + + // 停用依赖后出现问题(先停用无依赖者的 membership) + $manager->disable('neatstudio.membership'); + $manager->disable('neatstudio.payment'); + $this->assertNotEmpty($manager->dependencyProblems('neatstudio.membership')); + + // 不存在的插件没有依赖问题(空 requires) + $this->assertSame([], $manager->dependencyProblems('neatstudio.does-not-exist')); + } +} diff --git a/tests/Feature/PostFormInjectionTest.php b/tests/Feature/PostFormInjectionTest.php new file mode 100644 index 0000000..ee76fc9 --- /dev/null +++ b/tests/Feature/PostFormInjectionTest.php @@ -0,0 +1,70 @@ +getComponents()) + ->map(fn ($component) => method_exists($component, 'getHeading') ? $component->getHeading() : $component->getLabel()) + ->all(); + + $this->assertContains('付费设置', $headings); + + // 找到付费设置 Section;无 Livewire 环境下用反射读取默认内容槽(childComponents 按 schema key 分组) + $section = collect($schema->getComponents()) + ->first(fn ($component) => $component->getHeading() === '付费设置'); + + $property = (new ReflectionClass($section))->getProperty('childComponents'); + $fieldNames = collect($property->getValue($section)['default'] ?? []) + ->map(fn ($field) => $field->getName()) + ->all(); + + $this->assertContains('meta.price', $fieldNames); + $this->assertContains('meta.members_only', $fieldNames); + } + + public function test_post_table_includes_price_and_member_columns(): void + { + // Table 构造器要求 HasTable Livewire 组件,测试中绕过构造器 + $table = (new ReflectionClass(Table::class))->newInstanceWithoutConstructor(); + PostResource::table($table); + + $names = array_keys($table->getColumns()); + + $this->assertContains('meta.price', $names); + $this->assertContains('meta.members_only', $names); + $this->assertContains('title', $names); + } + + public function test_meta_price_maps_to_post_meta_json(): void + { + $post = \App\Models\Post::create([ + 'title' => '付费文章', + 'content' => '内容', + 'content_format' => 'markdown', + 'status' => 'published', + 'published_at' => now(), + 'meta' => ['price' => 9900, 'members_only' => true], + ]); + + $this->assertSame(9900, $post->meta['price']); + $this->assertTrue($post->meta['members_only']); + } +}