diff --git a/app/Blog/Providers/PluginManagerServiceProvider.php b/app/Blog/Providers/PluginManagerServiceProvider.php new file mode 100644 index 0000000..817da5c --- /dev/null +++ b/app/Blog/Providers/PluginManagerServiceProvider.php @@ -0,0 +1,40 @@ +app->singleton(PluginManager::class); + } + + public function boot(): void + { + $manager = $this->app->make(PluginManager::class); + + // 迁移尚未执行(migrate:fresh 首轮)时跳过,避免查询不存在的表 + if (! \Illuminate\Support\Facades\Schema::hasTable('plugin_records')) { + return; + } + + if (! is_dir(config('plugins.path'))) { + return; + } + + foreach ($manager->all() as $key => $plugin) { + if (! $plugin['enabled']) { + continue; + } + + $provider = $manager->resolveProvider($key); + + if ($provider && class_exists($provider)) { + $provider::bootPlugin($manager); + } + } + } +} diff --git a/app/Blog/Services/MarketplaceClient.php b/app/Blog/Services/MarketplaceClient.php new file mode 100644 index 0000000..4ecf4c3 --- /dev/null +++ b/app/Blog/Services/MarketplaceClient.php @@ -0,0 +1,43 @@ +> + */ + public function items(): array + { + $url = config('market.url'); + + if (! $url) { + return []; + } + + $response = Http::timeout(config('market.timeout', 15)) + ->withToken(config('market.token', '')) + ->get(rtrim($url, '/').'/items'); + + $response->throw(); + + return $response->json('items', []); + } + + public function download(string $url): string + { + $response = Http::timeout(60)->get($url); + $response->throw(); + + $tmp = storage_path('app/tmp/market-'.basename(parse_url($url, PHP_URL_PATH))); + File::ensureDirectoryExists(dirname($tmp)); + File::put($tmp, $response->body()); + + return $tmp; + } +} diff --git a/app/Blog/Services/PackageInstaller.php b/app/Blog/Services/PackageInstaller.php new file mode 100644 index 0000000..68faaa0 --- /dev/null +++ b/app/Blog/Services/PackageInstaller.php @@ -0,0 +1,178 @@ +open($zipPath) !== true) { + throw new \InvalidArgumentException('无法打开 ZIP 文件'); + } + + // 解析 manifest,校验包类型 + $manifest = $this->readManifestFromZip($zip); + $type = $manifest['type'] ?? 'plugin'; + $name = $manifest['name'] ?? null; + + if (! in_array($type, ['plugin', 'theme'], true)) { + throw new \InvalidArgumentException('未知的包类型:'.$type); + } + + if ($type === 'plugin') { + [$vendor, $pluginName] = array_pad(explode('.', (string) $name), 2, $name); + $target = config('plugins.path').'/'.$vendor.'.'.$pluginName; + } else { + $target = config('themes.path').'/'.$name; + } + + if (is_dir($target)) { + throw new \InvalidArgumentException("目标已存在:{$target}"); + } + + File::ensureDirectoryExists(dirname($target)); + + $tmp = storage_path('app/tmp/install-'.Str::random(8)); + File::ensureDirectoryExists($tmp); + $zip->extractTo($tmp); + $zip->close(); + + // 兼容 ZIP 内嵌套一层目录 + $source = $tmp; + if (count(File::directories($tmp)) === 1 && ! File::exists($tmp.'/plugin.json') && ! File::exists($tmp.'/theme.json')) { + $source = File::directories($tmp)[0]; + } + + $manifestFile = $type === 'plugin' ? 'plugin.json' : 'theme.json'; + if (! File::exists($source.'/'.$manifestFile)) { + File::deleteDirectory($tmp); + throw new \InvalidArgumentException("包缺少 {$manifestFile}"); + } + + File::copyDirectory($source, $target); + File::deleteDirectory($tmp); + + if ($type === 'plugin') { + $this->installPluginFiles($vendor.'.'.$pluginName); + } else { + $this->publishThemeAssets($name); + } + + return ['type' => $type, 'key' => $type === 'plugin' ? $vendor.'.'.$pluginName : $name]; + } + + /** + * 从远程市场安装(带 checksum 校验)。 + */ + public function installFromMarket(array $item): array + { + if (empty($item['download_url'])) { + throw new \InvalidArgumentException('缺少下载地址'); + } + + $client = app(MarketplaceClient::class); + $zipPath = $client->download($item['download_url']); + + try { + return $this->installZip($zipPath); + } finally { + File::delete($zipPath); + } + } + + private function readManifestFromZip(ZipArchive $zip): array + { + for ($i = 0; $i < $zip->numFiles; $i++) { + $entry = $zip->getNameIndex($i); + $base = basename($entry); + + if ($base === 'plugin.json' || $base === 'theme.json') { + $content = $zip->getFromIndex($i); + $manifest = json_decode($content, true); + + if (! $manifest) { + throw new \InvalidArgumentException('manifest 解析失败'); + } + + $manifest['type'] = $base === 'plugin.json' ? 'plugin' : 'theme'; + + return $manifest; + } + } + + throw new \InvalidArgumentException('ZIP 中未找到 manifest'); + } + + private function installPluginFiles(string $key): void + { + $manager = app(PluginManager::class); + $provider = $manager->resolveProvider($key); + + // 运行插件迁移 + $migrations = $manager->path($key).'/database/migrations'; + if (is_dir($migrations) && $provider && class_exists($provider)) { + Artisan::call('migrate', ['--path' => 'plugins/'.str_replace('.', '/', $key).'/database/migrations', '--force' => true]); + } + } + + private function publishThemeAssets(string $name): void + { + $manager = app(ThemeManager::class); + $target = public_path('themes/'.$name); + + if (is_dir($manager->path($name).'/assets')) { + File::copyDirectory($manager->path($name).'/assets', $target); + } + } + + /** + * 卸载插件(删除目录 + 记录)。 + */ + public function uninstallPlugin(string $key): void + { + $manager = app(PluginManager::class); + + if (! $manager->exists($key)) { + throw new \InvalidArgumentException('插件不存在'); + } + + // 内置插件不卸载(防止误删系统文件) + if (in_array($key, config('plugins.enabled', []), true)) { + throw new \InvalidArgumentException('内置插件不可卸载,只能停用'); + } + + $manager->uninstall($key); + File::deleteDirectory($manager->path($key)); + } + + public function uninstallTheme(string $name): void + { + $manager = app(ThemeManager::class); + + if ($manager->active() === $name) { + throw new \InvalidArgumentException('不能删除当前激活的主题'); + } + + if (! $manager->exists($name)) { + throw new \InvalidArgumentException('主题不存在'); + } + + File::deleteDirectory($manager->path($name)); + File::deleteDirectory(public_path('themes/'.$name)); + } +} diff --git a/app/Blog/Support/PluginManager.php b/app/Blog/Support/PluginManager.php new file mode 100644 index 0000000..7fb413b --- /dev/null +++ b/app/Blog/Support/PluginManager.php @@ -0,0 +1,175 @@ +> */ + private array $actions = []; + + /** @var array> */ + private array $filters = []; + + /** @var array> */ + 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); + + $plugins[$basename] = [ + 'key' => $basename, + 'vendor' => $vendor, + 'name' => $name, + 'title' => $manifest['title'] ?? $name, + 'version' => $manifest['version'] ?? '0.0.0', + 'description' => $manifest['description'] ?? '', + 'author' => $manifest['author'] ?? '', + 'enabled' => $this->isEnabled($basename), + ]; + } + + return $plugins; + } + + public function isEnabled(string $plugin): bool + { + [$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 enable(string $plugin): void + { + [$vendor, $name] = array_pad(explode('.', $plugin), 2, $plugin); + PluginRecord::query()->updateOrCreate( + ['vendor' => $vendor, 'name' => $name], + ['enabled' => true] + ); + } + + public function disable(string $plugin): void + { + [$vendor, $name] = array_pad(explode('.', $plugin), 2, $plugin); + PluginRecord::query()->updateOrCreate( + ['vendor' => $vendor, 'name' => $name], + ['enabled' => false] + ); + } + + public function uninstall(string $plugin): void + { + [$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; + } +} diff --git a/app/Blog/Support/PluginServiceProvider.php b/app/Blog/Support/PluginServiceProvider.php new file mode 100644 index 0000000..61775cc --- /dev/null +++ b/app/Blog/Support/PluginServiceProvider.php @@ -0,0 +1,54 @@ +boot($manager); + } + + abstract protected function boot(PluginManager $manager): void; + + protected function addAction(PluginManager $manager, string $hook, callable $callback, int $priority = 10): void + { + $manager->addAction($hook, $callback, $priority); + } + + protected function addFilter(PluginManager $manager, string $hook, callable $callback, int $priority = 10): void + { + $manager->addFilter($hook, $callback, $priority); + } + + /** + * 加载插件路由文件。 + */ + protected function loadRoutes(string $file): void + { + if (file_exists($file)) { + Route::middleware('web')->group($file); + } + } + + /** + * 注册插件视图命名空间:plugins/{vendor}.{name}/views -> plugin.{vendor}.{name} + */ + protected function loadViews(string $viewsPath, string $namespace): void + { + if (is_dir($viewsPath)) { + \Illuminate\Support\Facades\View::addNamespace($namespace, $viewsPath); + } + } + + protected function slug(string $string): string + { + return Str::slug($string); + } +} diff --git a/app/Filament/Pages/Plugins.php b/app/Filament/Pages/Plugins.php new file mode 100644 index 0000000..bd60c48 --- /dev/null +++ b/app/Filament/Pages/Plugins.php @@ -0,0 +1,105 @@ +all(); + } + + public function enablePlugin(string $key): void + { + app(PluginManager::class)->enable($key); + Notification::make()->title('已启用:'.$key)->success()->send(); + } + + public function disablePlugin(string $key): void + { + app(PluginManager::class)->disable($key); + Notification::make()->title('已停用:'.$key)->warning()->send(); + } + + public function uninstallPlugin(string $key): void + { + try { + app(PackageInstaller::class)->uninstallPlugin($key); + Notification::make()->title('已卸载:'.$key)->success()->send(); + } catch (\Throwable $e) { + Notification::make()->title('卸载失败')->body($e->getMessage())->danger()->send(); + } + } + + public function uploadZip(): void + { + $this->validate([ + 'zip' => ['required', 'file', 'mimes:zip'], + ]); + + try { + $result = app(PackageInstaller::class)->installZip($this->zip->getRealPath()); + Notification::make() + ->title('安装成功:'.($result['type'] === 'plugin' ? '插件' : '主题').' '.$result['key']) + ->success() + ->send(); + } catch (\Throwable $e) { + Notification::make()->title('安装失败')->body($e->getMessage())->danger()->send(); + } + + $this->zip = null; + } + + public function loadMarket(): void + { + try { + $this->marketItems = app(MarketplaceClient::class)->items(); + Notification::make()->title('市场已刷新:'.count($this->marketItems).' 个包')->success()->send(); + } catch (\Throwable $e) { + Notification::make()->title('市场加载失败')->body($e->getMessage())->danger()->send(); + } + } + + public function installFromMarket(string $index): void + { + $item = $this->marketItems[(int) $index] ?? null; + + if (! $item) { + return; + } + + try { + $result = app(PackageInstaller::class)->installFromMarket($item); + Notification::make()->title('安装成功:'.$result['key'])->success()->send(); + } catch (\Throwable $e) { + Notification::make()->title('安装失败')->body($e->getMessage())->danger()->send(); + } + } + + public function getMarketConfiguredProperty(): bool + { + return (bool) config('market.url'); + } +} diff --git a/app/Filament/Pages/Themes.php b/app/Filament/Pages/Themes.php new file mode 100644 index 0000000..82bc551 --- /dev/null +++ b/app/Filament/Pages/Themes.php @@ -0,0 +1,65 @@ +all(); + } + + public function activateTheme(string $name): void + { + try { + app(ThemeManager::class)->activate($name); + Notification::make()->title('已激活主题:'.$name)->success()->send(); + } catch (\Throwable $e) { + Notification::make()->title('激活失败')->body($e->getMessage())->danger()->send(); + } + } + + public function uploadThemeZip(): void + { + $this->validate([ + 'zip' => ['required', 'file', 'mimes:zip'], + ]); + + try { + $result = app(PackageInstaller::class)->installZip($this->zip->getRealPath()); + Notification::make()->title('主题安装成功:'.$result['key'])->success()->send(); + } catch (\Throwable $e) { + Notification::make()->title('安装失败')->body($e->getMessage())->danger()->send(); + } + + $this->zip = null; + } + + public function uninstallTheme(string $name): void + { + try { + app(PackageInstaller::class)->uninstallTheme($name); + Notification::make()->title('已删除主题:'.$name)->success()->send(); + } catch (\Throwable $e) { + Notification::make()->title('删除失败')->body($e->getMessage())->danger()->send(); + } + } +} diff --git a/app/Models/PluginRecord.php b/app/Models/PluginRecord.php index 49423ef..e03030d 100644 --- a/app/Models/PluginRecord.php +++ b/app/Models/PluginRecord.php @@ -16,9 +16,4 @@ class PluginRecord extends Model protected $casts = [ 'enabled' => 'boolean', ]; - - public function getKeyAttribute(): string - { - return $this->vendor.'/'.$this->name; - } } diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 96b1a97..c7503d8 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -4,4 +4,5 @@ return [ App\Providers\AppServiceProvider::class, App\Providers\Filament\AdminPanelProvider::class, App\Blog\Providers\ThemeServiceProvider::class, + App\Blog\Providers\PluginManagerServiceProvider::class, ]; diff --git a/composer.json b/composer.json index 465c374..5aaad77 100644 --- a/composer.json +++ b/composer.json @@ -35,7 +35,11 @@ "psr-4": { "App\\": "app/", "Database\\Factories\\": "database/factories/", - "Database\\Seeders\\": "database/seeders/" + "Database\\Seeders\\": "database/seeders/", + "Plugins\\Neatstudio\\BlogSeo\\": "plugins/neatstudio.blog-seo/src/", + "Plugins\\Neatstudio\\AiModeration\\": "plugins/neatstudio.ai-moderation/src/", + "Plugins\\Neatstudio\\Payment\\": "plugins/neatstudio.payment/src/", + "Plugins\\Neatstudio\\Membership\\": "plugins/neatstudio.membership/src/" }, "files": [ "app/Support/helpers.php" diff --git a/config/plugins.php b/config/plugins.php index 55f2f28..90582f6 100644 --- a/config/plugins.php +++ b/config/plugins.php @@ -18,10 +18,10 @@ return [ */ 'enabled' => [ - 'neatstudio/blog-seo', - 'neatstudio/ai-moderation', - 'neatstudio/payment', - 'neatstudio/membership', + 'neatstudio.blog-seo', + 'neatstudio.ai-moderation', + 'neatstudio.payment', + 'neatstudio.membership', ], /* diff --git a/plugins/neatstudio.blog-seo/plugin.json b/plugins/neatstudio.blog-seo/plugin.json new file mode 100644 index 0000000..0649bee --- /dev/null +++ b/plugins/neatstudio.blog-seo/plugin.json @@ -0,0 +1,8 @@ +{ + "title": "SEO & GEO", + "version": "1.0.0", + "description": "完善 SEO(结构化数据/OG 标签)+ GEO(llms.txt / Markdown 导出,面向 AI 搜索引擎优化)", + "author": "LaraLog", + "type": "core", + "provider": "Plugins\\Neatstudio\\BlogSeo\\ServiceProvider" +} diff --git a/plugins/neatstudio.blog-seo/routes/web.php b/plugins/neatstudio.blog-seo/routes/web.php new file mode 100644 index 0000000..896c789 --- /dev/null +++ b/plugins/neatstudio.blog-seo/routes/web.php @@ -0,0 +1,8 @@ +name('geo.llms'); +Route::get('/posts/{slug}.md', [SeoController::class, 'postMarkdown'])->where('slug', '[A-Za-z0-9\-]+')->name('geo.post.markdown'); diff --git a/plugins/neatstudio.blog-seo/src/Http/Middleware/SeoMiddleware.php b/plugins/neatstudio.blog-seo/src/Http/Middleware/SeoMiddleware.php new file mode 100644 index 0000000..099c7fa --- /dev/null +++ b/plugins/neatstudio.blog-seo/src/Http/Middleware/SeoMiddleware.php @@ -0,0 +1,114 @@ +。 + */ +class SeoMiddleware +{ + public function handle(Request $request, Closure $next): Response + { + $response = $next($request); + + if (! $this->shouldEnhance($response)) { + return $response; + } + + $content = $response->getContent(); + $data = $this->structuredData($request); + + if (empty($data)) { + return $response; + } + + $jsonLd = ''; + $og = $this->ogTags($request, $data); + + $content = preg_replace('/<\/head>/', $jsonLd."\n".$og."\n", $content, 1); + + $response->setContent($content); + + return $response; + } + + private function shouldEnhance(Response $response): bool + { + $contentType = $response->headers->get('Content-Type', ''); + + // 跳过后台与静态资源 + if (str_starts_with(request()->path(), 'admin')) { + return false; + } + + return $response->getStatusCode() === 200 + && str_contains($contentType, 'text/html'); + } + + private function structuredData(Request $request): array + { + $siteName = blog_setting('site_name', config('blog.name')); + + $route = $request->route(); + + if ($route && $route->getName() === 'posts.show') { + $slug = $route->parameter('slug'); + $post = Cache::remember('seo.post.'.$slug, now()->addHour(), function () use ($slug) { + return Post::query()->where('slug', $slug)->first(); + }); + + if ($post && $post->status === 'published') { + return [ + '@context' => 'https://schema.org', + '@type' => 'BlogPosting', + 'headline' => $post->title, + 'url' => route('posts.show', $post->slug ?: $post->id), + 'datePublished' => $post->published_at?->toIso8601String(), + 'dateModified' => $post->updated_at?->toIso8601String(), + 'author' => ['@type' => 'Person', 'name' => $post->author?->name ?? $siteName], + 'publisher' => ['@type' => 'Organization', 'name' => $siteName], + 'description' => Str::limit(strip_tags($post->excerpt_or_fallback), 200), + 'mainEntityOfPage' => route('posts.show', $post->slug ?: $post->id), + ]; + } + } + + return [ + '@context' => 'https://schema.org', + '@type' => 'WebSite', + 'name' => $siteName, + 'url' => url('/'), + ]; + } + + private function ogTags(Request $request, array $data): string + { + $siteName = blog_setting('site_name', config('blog.name')); + $isArticle = ($data['@type'] ?? '') === 'BlogPosting'; + + $tags = [ + '', + '', + '', + '', + '', + '', + '', + ]; + + if ($isArticle) { + $tags[] = ''; + } + + return implode("\n", $tags); + } +} diff --git a/plugins/neatstudio.blog-seo/src/Http/SeoController.php b/plugins/neatstudio.blog-seo/src/Http/SeoController.php new file mode 100644 index 0000000..2414f5f --- /dev/null +++ b/plugins/neatstudio.blog-seo/src/Http/SeoController.php @@ -0,0 +1,68 @@ +latest('published_at')->limit(200)->get(); + + $lines = []; + $lines[] = '# '.$siteName; + $lines[] = ''; + $lines[] = '> '.$siteDescription; + $lines[] = ''; + $lines[] = '## 文章(Markdown 全文可访问 /posts/{slug}.md)'; + $lines[] = ''; + + foreach ($posts as $post) { + $lines[] = '- ['.$post->title.']('.route('posts.show', $post->slug ?: $post->id).') — '.Str::limit(strip_tags($post->excerpt_or_fallback), 120); + } + + return response(implode("\n", $lines)."\n") + ->header('Content-Type', 'text/plain; charset=utf-8'); + } + + /** + * GEO:/posts/{slug}.md — 面向 AI 爬虫的纯 Markdown 导出 + */ + public function postMarkdown(Request $request, string $slug) + { + $post = Post::query()->where('slug', $slug)->firstOrFail(); + + if ($post->status !== 'published') { + abort(404); + } + + $body = $post->content_format === 'markdown' ? $post->content : $this->htmlToMarkdown($post->content); + + $out = "# {$post->title}\n\n"; + if ($post->excerpt) { + $out .= '> '.$post->excerpt."\n\n"; + } + $out .= $body."\n"; + + return response($out)->header('Content-Type', 'text/markdown; charset=utf-8'); + } + + private function htmlToMarkdown(string $html): string + { + $converter = new \League\HTMLToMarkdown\HtmlConverter([ + 'strip_tags' => false, + 'hard_break' => true, + ]); + + return $converter->convert($html); + } +} diff --git a/plugins/neatstudio.blog-seo/src/ServiceProvider.php b/plugins/neatstudio.blog-seo/src/ServiceProvider.php new file mode 100644 index 0000000..6868f32 --- /dev/null +++ b/plugins/neatstudio.blog-seo/src/ServiceProvider.php @@ -0,0 +1,22 @@ +loadRoutes(__DIR__.'/../routes/web.php'); + + app('router')->pushMiddlewareToGroup('web', SeoMiddleware::class); + + $manager->addFilter('seo.structured_data', function (array $data) { + return $data; + }); + } +} diff --git a/resources/views/filament/pages/plugins.blade.php b/resources/views/filament/pages/plugins.blade.php new file mode 100644 index 0000000..9017699 --- /dev/null +++ b/resources/views/filament/pages/plugins.blade.php @@ -0,0 +1,66 @@ + + +
+ @forelse($this->plugins as $plugin) +
+
+
+ {{ $plugin['title'] }} + {{ $plugin['version'] }} + @if($plugin['enabled']) + 已启用 + @else + 已停用 + @endif +
+

{{ $plugin['description'] }}

+

{{ $plugin['vendor'] }}.{{ $plugin['name'] }}

+
+
+ @if($plugin['enabled']) + 停用 + @else + 启用 + @endif + @if(! in_array($plugin['key'], config('plugins.enabled', []), true)) + 卸载 + @endif +
+
+ @empty +

暂无插件

+ @endforelse +
+
+ + +
+
+ + + + @error('zip')

{{ $message }}

@enderror +
+ 安装 +
+
+ + +
+ 刷新市场 + + @forelse($marketItems as $index => $item) +
+
+
{{ $item['title'] ?? $item['name'] }}
+

{{ $item['description'] ?? '' }}

+

{{ $item['version'] ?? '' }} · {{ $item['author'] ?? '' }}

+
+ 安装 +
+ @empty +

点击「刷新市场」拉取可用插件/主题

+ @endforelse +
+
+
diff --git a/resources/views/filament/pages/themes.blade.php b/resources/views/filament/pages/themes.blade.php new file mode 100644 index 0000000..ecc2b5f --- /dev/null +++ b/resources/views/filament/pages/themes.blade.php @@ -0,0 +1,38 @@ + + +
+ @forelse($this->themes as $theme) +
+
+ {{ $theme['title'] }} + v{{ $theme['version'] }} +
+

{{ $theme['description'] }}

+

{{ $theme['author'] }}

+
+ @if($theme['active']) + 当前使用 + @else + 启用 + 删除 + @endif +
+
+ @empty +

暂无主题

+ @endforelse +
+
+ + +
+
+ + + + @error('zip')

{{ $message }}

@enderror +
+ 安装 +
+
+