M4: 插件框架(钩子系统/ZIP安装/市场客户端)+ blog-seo 插件(JSON-LD/OG/llms.txt/Markdown导出)+ 后台插件/主题管理页
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Blog\Providers;
|
||||
|
||||
use App\Blog\Support\PluginManager;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class PluginManagerServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Blog\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class MarketplaceClient
|
||||
{
|
||||
/**
|
||||
* 拉取市场包列表。
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
namespace App\Blog\Services;
|
||||
|
||||
use App\Blog\Support\PluginManager;
|
||||
use App\Blog\Support\ThemeManager;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use ZipArchive;
|
||||
|
||||
class PackageInstaller
|
||||
{
|
||||
/**
|
||||
* 从 ZIP 安装插件或主题。
|
||||
*
|
||||
* @return array{type: string, key: string}
|
||||
*/
|
||||
public function installZip(string $zipPath): array
|
||||
{
|
||||
$zip = new ZipArchive;
|
||||
|
||||
if ($zip->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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace App\Blog\Support;
|
||||
|
||||
use App\Models\PluginRecord;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
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);
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Blog\Support;
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
abstract class PluginServiceProvider
|
||||
{
|
||||
/**
|
||||
* 插件入口:由 PluginManagerServiceProvider 在应用启动时调用。
|
||||
*/
|
||||
public static function bootPlugin(PluginManager $manager): void
|
||||
{
|
||||
(new static)->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Blog\Services\MarketplaceClient;
|
||||
use App\Blog\Services\PackageInstaller;
|
||||
use App\Blog\Support\PluginManager;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
class Plugins extends Page
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
protected static \UnitEnum|string|null $navigationGroup = '管理';
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-puzzle-piece';
|
||||
|
||||
protected static ?string $navigationLabel = '插件管理';
|
||||
|
||||
protected string $view = 'filament.pages.plugins';
|
||||
|
||||
public $zip;
|
||||
|
||||
public array $marketItems = [];
|
||||
|
||||
public function getPluginsProperty(): array
|
||||
{
|
||||
return app(PluginManager::class)->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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Blog\Services\PackageInstaller;
|
||||
use App\Blog\Support\ThemeManager;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
class Themes extends Page
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
protected static \UnitEnum|string|null $navigationGroup = '管理';
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-swatch';
|
||||
|
||||
protected static ?string $navigationLabel = '主题管理';
|
||||
|
||||
protected string $view = 'filament.pages.themes';
|
||||
|
||||
public $zip;
|
||||
|
||||
public function getThemesProperty(): array
|
||||
{
|
||||
return app(ThemeManager::class)->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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,4 @@ class PluginRecord extends Model
|
||||
protected $casts = [
|
||||
'enabled' => 'boolean',
|
||||
];
|
||||
|
||||
public function getKeyAttribute(): string
|
||||
{
|
||||
return $this->vendor.'/'.$this->name;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,4 +4,5 @@ return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
App\Providers\Filament\AdminPanelProvider::class,
|
||||
App\Blog\Providers\ThemeServiceProvider::class,
|
||||
App\Blog\Providers\PluginManagerServiceProvider::class,
|
||||
];
|
||||
|
||||
+5
-1
@@ -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"
|
||||
|
||||
+4
-4
@@ -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',
|
||||
],
|
||||
|
||||
/*
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Plugins\Neatstudio\BlogSeo\Http\SeoController;
|
||||
|
||||
// GEO:面向 AI 搜索引擎(LLM 爬虫)
|
||||
Route::get('/llms.txt', [SeoController::class, 'llmsTxt'])->name('geo.llms');
|
||||
Route::get('/posts/{slug}.md', [SeoController::class, 'postMarkdown'])->where('slug', '[A-Za-z0-9\-]+')->name('geo.post.markdown');
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Neatstudio\BlogSeo\Http\Middleware;
|
||||
|
||||
use App\Models\Post;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* SEO 增强中间件:注入 JSON-LD 结构化数据与 OG 标签。
|
||||
* 主题无关:直接改写响应 HTML 的 </head>。
|
||||
*/
|
||||
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 = '<script type="application/ld+json">'.json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES).'</script>';
|
||||
$og = $this->ogTags($request, $data);
|
||||
|
||||
$content = preg_replace('/<\/head>/', $jsonLd."\n".$og."\n</head>", $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 = [
|
||||
'<meta property="og:site_name" content="'.e($siteName).'">',
|
||||
'<meta property="og:type" content="'.($isArticle ? 'article' : 'website').'">',
|
||||
'<meta property="og:title" content="'.e($data['headline'] ?? $siteName).'">',
|
||||
'<meta property="og:url" content="'.e(url()->current()).'">',
|
||||
'<meta property="og:description" content="'.e($data['description'] ?? blog_setting('site_description', '')).'">',
|
||||
'<meta name="twitter:card" content="summary_large_image">',
|
||||
'<meta name="twitter:title" content="'.e($data['headline'] ?? $siteName).'">',
|
||||
];
|
||||
|
||||
if ($isArticle) {
|
||||
$tags[] = '<meta property="article:published_time" content="'.e($data['datePublished'] ?? '').'">';
|
||||
}
|
||||
|
||||
return implode("\n", $tags);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Neatstudio\BlogSeo\Http;
|
||||
|
||||
use App\Models\Post;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SeoController
|
||||
{
|
||||
/**
|
||||
* GEO:/llms.txt — 面向 LLM 的站点索引
|
||||
*/
|
||||
public function llmsTxt()
|
||||
{
|
||||
$siteName = blog_setting('site_name', config('blog.name'));
|
||||
$siteDescription = blog_setting('site_description', config('blog.description'));
|
||||
|
||||
$posts = Post::published()->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Neatstudio\BlogSeo;
|
||||
|
||||
use App\Blog\Support\PluginManager;
|
||||
use App\Blog\Support\PluginServiceProvider;
|
||||
use Plugins\Neatstudio\BlogSeo\Http\Middleware\SeoMiddleware;
|
||||
use Plugins\Neatstudio\BlogSeo\Http\SeoController;
|
||||
|
||||
class ServiceProvider extends PluginServiceProvider
|
||||
{
|
||||
protected function boot(PluginManager $manager): void
|
||||
{
|
||||
$this->loadRoutes(__DIR__.'/../routes/web.php');
|
||||
|
||||
app('router')->pushMiddlewareToGroup('web', SeoMiddleware::class);
|
||||
|
||||
$manager->addFilter('seo.structured_data', function (array $data) {
|
||||
return $data;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<x-filament-panels::page>
|
||||
<x-filament::section heading="插件列表">
|
||||
<div class="space-y-4">
|
||||
@forelse($this->plugins as $plugin)
|
||||
<div class="flex items-center justify-between gap-4 rounded-xl border border-gray-200 p-4 dark:border-white/10">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold">{{ $plugin['title'] }}</span>
|
||||
<span class="text-xs text-gray-500">{{ $plugin['version'] }}</span>
|
||||
@if($plugin['enabled'])
|
||||
<x-filament::badge color="success">已启用</x-filament::badge>
|
||||
@else
|
||||
<x-filament::badge color="gray">已停用</x-filament::badge>
|
||||
@endif
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-gray-500">{{ $plugin['description'] }}</p>
|
||||
<p class="mt-0.5 text-xs text-gray-400">{{ $plugin['vendor'] }}.{{ $plugin['name'] }}</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
@if($plugin['enabled'])
|
||||
<x-filament::button size="sm" color="gray" wire:click="disablePlugin('{{ $plugin['key'] }}')">停用</x-filament::button>
|
||||
@else
|
||||
<x-filament::button size="sm" color="success" wire:click="enablePlugin('{{ $plugin['key'] }}')">启用</x-filament::button>
|
||||
@endif
|
||||
@if(! in_array($plugin['key'], config('plugins.enabled', []), true))
|
||||
<x-filament::button size="sm" color="danger" wire:click="uninstallPlugin('{{ $plugin['key'] }}')" wire:confirm="确定卸载该插件?">卸载</x-filament::button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-sm text-gray-500">暂无插件</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section heading="安装插件(ZIP)">
|
||||
<form wire:submit="uploadZip" class="flex items-end gap-3">
|
||||
<div class="flex-1">
|
||||
<x-filament::input.wrapper>
|
||||
<x-filament::input type="file" wire:model="zip" accept=".zip" />
|
||||
</x-filament::input.wrapper>
|
||||
@error('zip') <p class="mt-1 text-sm text-danger-600">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<x-filament::button type="submit" color="primary">安装</x-filament::button>
|
||||
</form>
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section heading="插件市场" :description="config('market.url') ?: '未配置远程市场(config/market.php 的 MARKET_URL),仅支持本地 ZIP 安装'">
|
||||
<div class="space-y-3">
|
||||
<x-filament::button color="gray" wire:click="loadMarket">刷新市场</x-filament::button>
|
||||
|
||||
@forelse($marketItems as $index => $item)
|
||||
<div class="flex items-center justify-between gap-4 rounded-xl border border-gray-200 p-4 dark:border-white/10">
|
||||
<div>
|
||||
<div class="font-semibold">{{ $item['title'] ?? $item['name'] }}</div>
|
||||
<p class="text-sm text-gray-500">{{ $item['description'] ?? '' }}</p>
|
||||
<p class="text-xs text-gray-400">{{ $item['version'] ?? '' }} · {{ $item['author'] ?? '' }}</p>
|
||||
</div>
|
||||
<x-filament::button size="sm" color="primary" wire:click="installFromMarket({{ $index }})">安装</x-filament::button>
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-sm text-gray-500">点击「刷新市场」拉取可用插件/主题</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</x-filament-panels::page>
|
||||
@@ -0,0 +1,38 @@
|
||||
<x-filament-panels::page>
|
||||
<x-filament::section heading="主题列表">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
@forelse($this->themes as $theme)
|
||||
<div class="rounded-xl border border-gray-200 p-5 dark:border-white/10">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-semibold text-lg">{{ $theme['title'] }}</span>
|
||||
<span class="text-xs text-gray-500">v{{ $theme['version'] }}</span>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-gray-500">{{ $theme['description'] }}</p>
|
||||
<p class="mt-0.5 text-xs text-gray-400">{{ $theme['author'] }}</p>
|
||||
<div class="mt-3 flex gap-2">
|
||||
@if($theme['active'])
|
||||
<x-filament::badge color="success">当前使用</x-filament::badge>
|
||||
@else
|
||||
<x-filament::button size="sm" color="primary" wire:click="activateTheme('{{ $theme['name'] }}')">启用</x-filament::button>
|
||||
<x-filament::button size="sm" color="danger" wire:click="uninstallTheme('{{ $theme['name'] }}')" wire:confirm="确定删除该主题?">删除</x-filament::button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-sm text-gray-500">暂无主题</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section heading="安装主题(ZIP)">
|
||||
<form wire:submit="uploadThemeZip" class="flex items-end gap-3">
|
||||
<div class="flex-1">
|
||||
<x-filament::input.wrapper>
|
||||
<x-filament::input type="file" wire:model="zip" accept=".zip" />
|
||||
</x-filament::input.wrapper>
|
||||
@error('zip') <p class="mt-1 text-sm text-danger-600">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<x-filament::button type="submit" color="primary">安装</x-filament::button>
|
||||
</form>
|
||||
</x-filament::section>
|
||||
</x-filament-panels::page>
|
||||
Reference in New Issue
Block a user