From 3cec4c5e18bead042abf3e9a539db815d5863724 Mon Sep 17 00:00:00 2001 From: gouki Date: Mon, 7 Sep 2026 18:48:37 +0000 Subject: [PATCH] wip: article AI polish, category SEO fields, cover generator, membership plan seeder --- .env.example | 11 +- .gitignore | 42 ++- README.md | 9 +- app/Console/Commands/QueueAiWorkCommand.php | 9 +- app/Console/Commands/WorkermanAiCommand.php | 46 ++- .../Ai/Jobs/GenerateArticleCoverJob.php | 37 ++- .../Ai/Jobs/OptimizeArticleContentJob.php | 22 +- app/Domain/Ai/OpenAiCompatibleLlmProvider.php | 127 ++++--- app/Domain/Ai/StubLlmProvider.php | 6 +- app/Domain/Media/ArticleCoverGenerator.php | 167 ++++++++++ app/Domain/Media/ArticleCoverService.php | 182 ++++++++++ app/Domain/Plugin/PluginManager.php | 90 ++++- app/Domain/Seo/SeoPresenter.php | 59 ++++ app/Filament/Pages/ManagePlugins.php | 46 ++- app/Filament/Pages/MembershipPluginPage.php | 6 + .../Resources/Articles/ArticleResource.php | 9 +- .../Articles/Pages/CreateArticle.php | 8 +- .../Resources/Articles/Pages/EditArticle.php | 86 ++++- .../Resources/Articles/Pages/ListArticles.php | 2 +- .../CommentsRelationManager.php | 125 +++++++ .../Articles/Schemas/ArticleForm.php | 27 +- .../Articles/Tables/ArticlesTable.php | 53 ++- .../Attachments/Pages/ListAttachments.php | 2 +- .../Attachments/Tables/AttachmentsTable.php | 28 +- .../Categories/Pages/ListCategories.php | 2 +- .../Categories/Schemas/CategoryForm.php | 20 +- .../Categories/Tables/CategoriesTable.php | 23 +- .../Resources/Comments/Pages/ListComments.php | 2 +- .../Comments/Schemas/CommentForm.php | 20 +- .../Comments/Tables/CommentsTable.php | 42 ++- .../Resources/Links/Pages/ListLinks.php | 2 +- .../Resources/Links/Tables/LinksTable.php | 17 +- app/Filament/Resources/Pages/ListRecords.php | 21 ++ .../Resources/Plugins/Pages/ListPlugins.php | 2 +- .../Resources/Plugins/Tables/PluginsTable.php | 9 +- .../Stylevars/Pages/ListStylevars.php | 2 +- .../Stylevars/Tables/StylevarsTable.php | 26 +- .../Resources/Tags/Pages/ListTags.php | 2 +- .../Resources/Tags/Tables/TagsTable.php | 9 +- .../Resources/Users/Pages/ListUsers.php | 2 +- .../Resources/Users/Tables/UsersTable.php | 25 +- app/Filament/Support/AdminTable.php | 58 ++++ app/Http/Controllers/AuthController.php | 14 + app/Http/Controllers/BlogController.php | 43 ++- app/Http/Controllers/SeoController.php | 20 ++ app/Models/Article.php | 7 + app/Models/Category.php | 23 ++ app/Models/Comment.php | 14 + app/Providers/Filament/AdminPanelProvider.php | 11 + config/larablog.php | 3 + ...013201_add_article_ai_polished_content.php | 28 ++ ...6_08_13_015800_add_category_seo_fields.php | 27 ++ database/seeders/AiSettingsSeeder.php | 48 +++ database/seeders/DatabaseSeeder.php | 6 +- database/seeders/DemoBlogSeeder.php | 16 +- database/seeders/MembershipPlanSeeder.php | 23 ++ deploy/nginx.conf | 2 + docs/architecture.md | 1 + docs/ops/deploy.md | 249 ++++++++++++-- docs/ops/import.md | 164 +++++++++ docs/ops/install.md | 156 +++++++++ docs/plugins.md | 12 +- docs/specs/larablog-platform/CHECKLIST.md | 8 +- docs/specs/membership/CHECKLIST.md | 38 +++ docs/specs/membership/SPEC.md | 147 +++++++++ docs/specs/membership/TESTPLAN.md | 38 +++ .../plugin-extension-commerce/CHECKLIST.md | 4 +- .../plugin-extension-commerce/TESTPLAN.md | 6 +- ecosystem.config.cjs | 4 +- herdy.yaml | 4 - lang/en/admin.php | 43 ++- lang/en/frontend.php | 16 + lang/zh_CN/admin.php | 43 ++- lang/zh_CN/frontend.php | 16 + plugins/larablog/membership/README.md | 23 ++ plugins/larablog/membership/README.zh_CN.md | 28 ++ ...2_034600_create_membership_plans_table.php | 31 ++ ...034601_create_article_membership_table.php | 29 ++ plugins/larablog/membership/plugin.json | 12 +- .../resources/views/plans.blade.php | 57 ++++ .../Database/Seeders/MembershipPlanSeeder.php | 40 +++ .../src/Domain/MembershipService.php | 89 +++++ .../Resources/MembershipPlanResource.php | 134 ++++++++ .../Pages/CreateMembershipPlan.php | 13 + .../Pages/EditMembershipPlan.php | 43 +++ .../Pages/ListMembershipPlans.php | 21 ++ .../src/Models/ArticleMembership.php | 38 +++ .../membership/src/Models/MembershipPlan.php | 58 ++++ .../membership/src/PluginServiceProvider.php | 279 +++++++++++++++- plugins/larablog/paid-content/README.zh_CN.md | 35 ++ .../src/PluginServiceProvider.php | 80 ++++- plugins/larablog/payment/README.zh_CN.md | 50 +++ ...0_add_expires_at_to_entitlements_table.php | 28 ++ .../filament/pages/payment-settings.blade.php | 4 +- .../payment/src/Domain/OrderService.php | 26 +- .../Filament/Pages/PaymentSettingsPage.php | 4 +- .../src/Filament/Resources/OrderResource.php | 9 +- .../OrderResource/Pages/ListOrders.php | 2 +- .../payment/src/Models/Entitlement.php | 19 +- .../payment/src/PluginServiceProvider.php | 28 +- public/css/larablog-admin.css | 312 ++++++++++++++++++ public/themes/default/style.css | 66 ++++ resources/fonts/.gitkeep | 1 + resources/fonts/README.md | 5 + .../filament/partials/plugin-docs.blade.php | 7 + tests/Feature/AiPipelineTest.php | 12 +- tests/Feature/ArticleCoverTest.php | 126 +++++++ tests/Feature/BlogFrontendTest.php | 92 ++++++ tests/Feature/MembershipCommerceTest.php | 243 ++++++++++++++ tests/Feature/PaidContentCommerceTest.php | 16 +- tests/Feature/PluginDocsTest.php | 99 ++++++ tests/fixtures/sablog/README.md | 2 +- themes/default/assets/style.css | 46 +++ themes/default/views/article.blade.php | 8 +- themes/default/views/category.blade.php | 41 +++ themes/default/views/comments.blade.php | 2 +- themes/default/views/home.blade.php | 5 + themes/default/views/layout.blade.php | 7 +- .../views/partials/comment-author.blade.php | 8 + themes/default/views/profile.blade.php | 15 + themes/example/views/layout.blade.php | 4 +- 121 files changed, 4701 insertions(+), 313 deletions(-) create mode 100644 app/Domain/Media/ArticleCoverGenerator.php create mode 100644 app/Domain/Media/ArticleCoverService.php create mode 100644 app/Filament/Resources/Articles/RelationManagers/CommentsRelationManager.php create mode 100644 app/Filament/Resources/Pages/ListRecords.php create mode 100644 app/Filament/Support/AdminTable.php create mode 100644 database/migrations/2026_08_12_013201_add_article_ai_polished_content.php create mode 100644 database/migrations/2026_08_13_015800_add_category_seo_fields.php create mode 100644 database/seeders/AiSettingsSeeder.php create mode 100644 database/seeders/MembershipPlanSeeder.php create mode 100644 docs/ops/import.md create mode 100644 docs/ops/install.md create mode 100644 docs/specs/membership/CHECKLIST.md create mode 100644 docs/specs/membership/SPEC.md create mode 100644 docs/specs/membership/TESTPLAN.md delete mode 100644 herdy.yaml create mode 100644 plugins/larablog/membership/README.md create mode 100644 plugins/larablog/membership/README.zh_CN.md create mode 100644 plugins/larablog/membership/database/migrations/2026_08_12_034600_create_membership_plans_table.php create mode 100644 plugins/larablog/membership/database/migrations/2026_08_12_034601_create_article_membership_table.php create mode 100644 plugins/larablog/membership/resources/views/plans.blade.php create mode 100644 plugins/larablog/membership/src/Database/Seeders/MembershipPlanSeeder.php create mode 100644 plugins/larablog/membership/src/Domain/MembershipService.php create mode 100644 plugins/larablog/membership/src/Filament/Resources/MembershipPlanResource.php create mode 100644 plugins/larablog/membership/src/Filament/Resources/MembershipPlanResource/Pages/CreateMembershipPlan.php create mode 100644 plugins/larablog/membership/src/Filament/Resources/MembershipPlanResource/Pages/EditMembershipPlan.php create mode 100644 plugins/larablog/membership/src/Filament/Resources/MembershipPlanResource/Pages/ListMembershipPlans.php create mode 100644 plugins/larablog/membership/src/Models/ArticleMembership.php create mode 100644 plugins/larablog/membership/src/Models/MembershipPlan.php create mode 100644 plugins/larablog/paid-content/README.zh_CN.md create mode 100644 plugins/larablog/payment/README.zh_CN.md create mode 100644 plugins/larablog/payment/database/migrations/2026_08_12_034500_add_expires_at_to_entitlements_table.php create mode 100644 public/css/larablog-admin.css create mode 100644 resources/fonts/.gitkeep create mode 100644 resources/fonts/README.md create mode 100644 resources/views/filament/partials/plugin-docs.blade.php create mode 100644 tests/Feature/ArticleCoverTest.php create mode 100644 tests/Feature/MembershipCommerceTest.php create mode 100644 tests/Feature/PluginDocsTest.php create mode 100644 themes/default/views/category.blade.php create mode 100644 themes/default/views/partials/comment-author.blade.php diff --git a/.env.example b/.env.example index 977002b..847fb5f 100644 --- a/.env.example +++ b/.env.example @@ -88,9 +88,14 @@ LARABLOG_DEFAULT_CONTENT_FORMAT=markdown LARABLOG_IMPORT_CONTENT_FORMAT=html # AI provider: stub | openai_compatible -AI_PROVIDER=stub -AI_API_BASE_URL=https://api.openai.com/v1 +# After first migrate, run: php artisan db:seed --class=AiSettingsSeeder +# to copy these into Spatie settings (Filament Site Settings → AI). +AI_PROVIDER=openai_compatible +AI_API_BASE_URL=https://apihub.agnes-ai.com/v1 AI_API_KEY= -AI_MODEL=gpt-4o-mini +AI_MODEL=agnes-2.0-flash + +# Optional TTF/OTF for template cover text (Chinese-capable recommended) +# LARABLOG_COVER_FONT=/path/to/NotoSansSC-Regular.otf VITE_APP_NAME="${APP_NAME}" diff --git a/.gitignore b/.gitignore index 439bb4c..9e64055 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,14 @@ *.log .DS_Store +._* +Thumbs.db + +# Environment / secrets (keep .env.example tracked) .env -.env.backup -.env.production +.env.* +!.env.example + +# Local tooling / IDE .phpactor.json .phpunit.result.cache /.fleet @@ -13,13 +19,41 @@ /.vscode /.zed /auth.json + +# Dependencies & frontend build /node_modules /public/build /public/hot /public/storage +/vendor + +# Laravel runtime /storage/*.key /storage/pail -/vendor + +# Workerman / process runtime (also covers leftover files in project root) +*.pid +*.pid.lock +*.status +*.status.connection + +# Local databases (also covered by database/.gitignore) +*.sqlite +*.sqlite-journal +*.sqlite-shm +*.sqlite-wal + +# Homestead / Vagrant Homestead.json Homestead.yaml -Thumbs.db +/.vagrant + +# Package manager debug logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Coverage / misc build artifacts +/coverage +/build diff --git a/README.md b/README.md index c73ded5..754ca4f 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ Laravel 12 + Filament 5 + Livewire + Spatie + Workerman 的现代博客平台, ## 快速开始 +完整说明:[docs/ops/install.md](docs/ops/install.md)。迁旧站不要 `db:seed`,改走 [docs/ops/import.md](docs/ops/import.md)。 + ```bash composer install cp .env.example .env @@ -24,12 +26,14 @@ php artisan serve | 文档 | 内容 | |---|---| +| [docs/ops/install.md](docs/ops/install.md) | 本地安装、演示账号、插件 | +| [docs/ops/import.md](docs/ops/import.md) | sablog 导入(ID / 附件 / 旧密码) | +| [docs/ops/deploy.md](docs/ops/deploy.md) | 生产部署:nginx、PM2、Redis、S3 | | [docs/architecture.md](docs/architecture.md) | 架构、分层、开发思想 | | [docs/themes.md](docs/themes.md) | 皮肤目录格式与开发 | | [docs/plugins.md](docs/plugins.md) | 插件格式 / Hook | | [docs/routing.md](docs/routing.md) | `.shtml` 路由策略 | | [docs/api/](docs/api/) | OpenAPI + 小程序向只读 API | -| [docs/ops/deploy.md](docs/ops/deploy.md) | PM2 / Redis 前缀 / 部署 | | [docs/specs/larablog-platform/](docs/specs/larablog-platform/) | SPEC / CHECKLIST / TESTPLAN | ## 进程与 CI @@ -56,7 +60,10 @@ CACHE_PREFIX=larablog_cache_ ## sablog 导入 +完整步骤、编码、附件补传与导入后开后台:[docs/ops/import.md](docs/ops/import.md)。 + ```bash +php artisan sablog:import --mode=raw --dry-run php artisan sablog:import --mode=raw --attachments=/path/to/attachments php artisan sablog:import --mode=markdown --attachments=/path/to/attachments ``` diff --git a/app/Console/Commands/QueueAiWorkCommand.php b/app/Console/Commands/QueueAiWorkCommand.php index 9b1f592..c509fef 100644 --- a/app/Console/Commands/QueueAiWorkCommand.php +++ b/app/Console/Commands/QueueAiWorkCommand.php @@ -7,7 +7,10 @@ namespace App\Console\Commands; use Illuminate\Console\Command; /** - * Simple alternative to workerman:ai for local/dev: drain AI queues once or loop. + * Dev-friendly alternative to `workerman:ai`. + * + * Does NOT start Workerman. It only wraps Laravel's `queue:work` for the + * AI queues so local setups without pcntl/posix/PM2 can still drain jobs. */ class QueueAiWorkCommand extends Command { @@ -15,10 +18,12 @@ class QueueAiWorkCommand extends Command {--once : Process available jobs once and exit} {--max-time=60 : Max seconds when looping}'; - protected $description = 'Process ai-content and ai-moderation queues (dev-friendly alternative to workerman:ai)'; + protected $description = 'Drain ai-content/ai-moderation via queue:work (does not start Workerman; use workerman:ai for that)'; public function handle(): int { + $this->comment('queue:ai → Laravel queue:work (not Workerman). For a long-lived Workerman process use: php artisan workerman:ai start'); + $params = [ '--queue' => 'ai-content,ai-moderation', '--tries' => 3, diff --git a/app/Console/Commands/WorkermanAiCommand.php b/app/Console/Commands/WorkermanAiCommand.php index 127b87f..0bfedfa 100644 --- a/app/Console/Commands/WorkermanAiCommand.php +++ b/app/Console/Commands/WorkermanAiCommand.php @@ -10,22 +10,54 @@ use Workerman\Timer; use Workerman\Worker; /** - * Long-lived Workerman process for AI queues (content optimize + comment moderation). + * Long-lived Workerman process for AI queues (content polish + comment moderation). + * + * This is separate from `queue:ai`, which is a short-lived Laravel `queue:work` + * helper for local/dev. Production usually runs either this command OR a + * dedicated `queue:work` on the AI queues — not both. */ class WorkermanAiCommand extends Command { - protected $signature = 'workerman:ai {--count=1 : Worker processes}'; + protected $signature = 'workerman:ai + {action=start : start|stop|restart|reload|status|connections} + {--count=1 : Worker processes} + {--d : Daemonize (pass through to Workerman)}'; - protected $description = 'Start Workerman workers that process ai-content and ai-moderation queues'; + protected $description = 'Workerman long-running AI queue consumer (ai-content, ai-moderation). Not started by queue:ai.'; public function handle(): int { - $this->info('Starting Workerman AI runtime (queues: ai-content, ai-moderation)...'); + if (! extension_loaded('pcntl') || ! extension_loaded('posix')) { + $this->error('workerman:ai requires the pcntl and posix PHP extensions (Linux/macOS CLI).'); - Worker::$pidFile = storage_path('logs/workerman-ai.pid'); - Worker::$logFile = storage_path('logs/workerman-ai.log'); + return self::FAILURE; + } - $worker = new Worker(); + $action = (string) $this->argument('action'); + $allowed = ['start', 'stop', 'restart', 'reload', 'status', 'connections']; + + if (! in_array($action, $allowed, true)) { + $this->error('Unknown action. Use: '.implode('|', $allowed)); + + return self::FAILURE; + } + + // Workerman treats `artisan` as the start file, so unset paths default + // to the project root (workerman.log / workerman.artisan.status). + $runtime = storage_path('logs'); + if (! is_dir($runtime)) { + mkdir($runtime, 0775, true); + } + + Worker::$command = $action.($this->option('d') ? ' -d' : ''); + Worker::$pidFile = $runtime.'/workerman-ai.pid'; + Worker::$logFile = $runtime.'/workerman-ai.log'; + Worker::$statusFile = $runtime.'/workerman-ai.status'; + Worker::$stdoutFile = $runtime.'/workerman-ai.stdout.log'; + + $this->info("Workerman AI: {$action} (queues: ai-content, ai-moderation)"); + + $worker = new Worker; $worker->count = max(1, (int) $this->option('count')); $worker->name = 'larablog-ai'; diff --git a/app/Domain/Ai/Jobs/GenerateArticleCoverJob.php b/app/Domain/Ai/Jobs/GenerateArticleCoverJob.php index 1c46e5b..9d270db 100644 --- a/app/Domain/Ai/Jobs/GenerateArticleCoverJob.php +++ b/app/Domain/Ai/Jobs/GenerateArticleCoverJob.php @@ -4,41 +4,48 @@ declare(strict_types=1); namespace App\Domain\Ai\Jobs; +use App\Domain\Media\ArticleCoverService; use App\Models\Article; -use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; -use Illuminate\Foundation\Bus\Dispatchable; -use Illuminate\Queue\InteractsWithQueue; -use Illuminate\Queue\SerializesModels; +use Illuminate\Foundation\Queue\Queueable; use Illuminate\Support\Facades\Log; /** - * Phase-2 stub: auto-pick or generate a cover image for an article. - * Dispatch later from import scripts / artisan batch; process via Workerman/queue. + * Auto-pick a cover from article body embeds / attachments. + * `generate` strategy is reserved for future text-to-image. */ class GenerateArticleCoverJob implements ShouldQueue { - use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + use Queueable; public function __construct( public int $articleId, - public string $strategy = 'auto', // auto|from_content|generate + public string $strategy = 'auto', // auto|from_content|attachment|generate ) { $this->onQueue('ai-content'); } - public function handle(): void + public function handle(ArticleCoverService $covers): void { $article = Article::query()->find($this->articleId); + if ($article === null) { return; } - // Intentionally unimplemented in phase 1. - Log::info('GenerateArticleCoverJob stub skipped', [ - 'article_id' => $article->id, - 'strategy' => $this->strategy, - 'cover_status' => $article->cover_status, - ]); + try { + $covers->apply($article, $this->strategy); + } catch (\Throwable $exception) { + Log::warning('GenerateArticleCoverJob failed.', [ + 'article_id' => $this->articleId, + 'strategy' => $this->strategy, + 'message' => $exception->getMessage(), + ]); + + $article->forceFill([ + 'cover_status' => ArticleCoverService::STATUS_FAILED, + 'cover_generated_at' => now(), + ])->save(); + } } } diff --git a/app/Domain/Ai/Jobs/OptimizeArticleContentJob.php b/app/Domain/Ai/Jobs/OptimizeArticleContentJob.php index 9febd99..2ae03b8 100644 --- a/app/Domain/Ai/Jobs/OptimizeArticleContentJob.php +++ b/app/Domain/Ai/Jobs/OptimizeArticleContentJob.php @@ -34,15 +34,27 @@ class OptimizeArticleContentJob implements ShouldQueue } try { - $result = $llm->complete($article->content, [ + $result = $llm->complete((string) $article->content, [ 'title' => $article->title, 'article_id' => $article->id, + 'content_format' => $article->content_format, ]); - $article->forceFill([ - 'ai_summary' => $result['summary'] ?? null, - 'ai_suggestions' => $result['suggestions'] ?? [], - ])->save(); + $updates = [ + 'ai_summary' => filled($result['summary'] ?? null) ? (string) $result['summary'] : $article->ai_summary, + 'ai_suggestions' => array_values($result['suggestions'] ?? []), + ]; + + if (filled($result['polished_content'] ?? null)) { + $updates['ai_polished_content'] = (string) $result['polished_content']; + } + + // Fill empty SEO description from the model when the author left it blank. + if (blank($article->description) && filled($result['description'] ?? null)) { + $updates['description'] = (string) $result['description']; + } + + $article->forceFill($updates)->save(); } catch (\Throwable $exception) { Log::warning('Article content optimization failed.', [ 'article_id' => $this->articleId, diff --git a/app/Domain/Ai/OpenAiCompatibleLlmProvider.php b/app/Domain/Ai/OpenAiCompatibleLlmProvider.php index b57a372..1da9ca9 100644 --- a/app/Domain/Ai/OpenAiCompatibleLlmProvider.php +++ b/app/Domain/Ai/OpenAiCompatibleLlmProvider.php @@ -17,58 +17,64 @@ class OpenAiCompatibleLlmProvider implements LlmProvider public function complete(string $prompt, array $context = []): array { - $response = $this->request([ - 'model' => $this->settings->model ?? 'gpt-4o-mini', - 'messages' => [ - [ - 'role' => 'system', - 'content' => 'You optimize blog article content. Respond with JSON containing summary (string) and suggestions (array of strings).', - ], - [ - 'role' => 'user', - 'content' => $prompt, - ], + $title = (string) ($context['title'] ?? ''); + $format = (string) ($context['content_format'] ?? 'markdown'); + $formatHint = $format === 'html' + ? 'Keep valid HTML (no markdown). Preserve existing tags where useful.' + : 'Keep Markdown (no raw HTML unless already present). Preserve headings/lists/code fences.'; + + $response = $this->chat([ + [ + 'role' => 'system', + 'content' => implode("\n", [ + 'You are an editor polishing blog posts for LaraBlog (Chinese and English OK).', + 'Improve clarity, flow, and grammar without inventing facts or changing the author\'s intent.', + $formatHint, + 'Respond with JSON only (no markdown fences):', + '{"summary":"short overview","description":"SEO description <=160 chars","polished_content":"full polished body","suggestions":["tip1","tip2"]}', + ]), ], - 'response_format' => ['type' => 'json_object'], - ]); + [ + 'role' => 'user', + 'content' => "Title: {$title}\nFormat: {$format}\n\n---\n{$prompt}", + ], + ], preferJsonObject: true); $content = data_get($response, 'choices.0.message.content'); - - if (! is_string($content)) { - throw new RuntimeException('LLM completion response missing content.'); - } - - $decoded = json_decode($content, true); + $decoded = is_string($content) ? $this->decodeJsonObject($content) : null; if (! is_array($decoded)) { throw new RuntimeException('LLM completion response is not valid JSON.'); } + $suggestions = $decoded['suggestions'] ?? []; + if (! is_array($suggestions)) { + $suggestions = []; + } + return [ 'summary' => (string) ($decoded['summary'] ?? ''), - 'suggestions' => array_values($decoded['suggestions'] ?? []), + 'description' => (string) ($decoded['description'] ?? ''), + 'polished_content' => (string) ($decoded['polished_content'] ?? $decoded['content'] ?? ''), + 'suggestions' => array_values(array_map('strval', $suggestions)), ]; } public function moderate(string $content): array { - $response = $this->request([ - 'model' => $this->settings->model ?? 'gpt-4o-mini', - 'messages' => [ - [ - 'role' => 'system', - 'content' => 'Moderate blog comments. Respond with JSON: {"status":"approved|rejected|needs_human","reason":"..."}', - ], - [ - 'role' => 'user', - 'content' => $content, - ], + $response = $this->chat([ + [ + 'role' => 'system', + 'content' => 'You moderate Chinese and English blog comments for spam, ads, scams, abuse, and irrelevant promo. Respond with JSON only (no markdown): {"status":"approved|rejected|needs_human","reason":"..."}. Reject clear spam/ads; approve normal discussion; use needs_human when unsure.', ], - 'response_format' => ['type' => 'json_object'], - ]); + [ + 'role' => 'user', + 'content' => $content, + ], + ], preferJsonObject: true); $payload = data_get($response, 'choices.0.message.content'); - $decoded = is_string($payload) ? json_decode($payload, true) : null; + $decoded = is_string($payload) ? $this->decodeJsonObject($payload) : null; if (! is_array($decoded) || ! isset($decoded['status'])) { return [ @@ -77,12 +83,41 @@ class OpenAiCompatibleLlmProvider implements LlmProvider ]; } + $status = (string) $decoded['status']; + if (! in_array($status, ['approved', 'rejected', 'needs_human'], true)) { + $status = 'needs_human'; + } + return [ - 'status' => (string) $decoded['status'], + 'status' => $status, 'reason' => isset($decoded['reason']) ? (string) $decoded['reason'] : null, ]; } + /** + * @param list $messages + * @return array + */ + protected function chat(array $messages, bool $preferJsonObject = false): array + { + $payload = [ + 'model' => $this->settings->model ?? 'gpt-4o-mini', + 'messages' => $messages, + ]; + + if ($preferJsonObject) { + try { + return $this->request($payload + [ + 'response_format' => ['type' => 'json_object'], + ]); + } catch (\Throwable) { + // Some OpenAI-compatible gateways (e.g. Agnes) reject response_format. + } + } + + return $this->request($payload); + } + /** * @param array $payload * @return array @@ -91,9 +126,9 @@ class OpenAiCompatibleLlmProvider implements LlmProvider { $baseUrl = rtrim($this->settings->api_base_url ?? 'https://api.openai.com/v1', '/'); - $response = Http::withToken($this->settings->api_key ?? '') + $response = Http::withToken((string) ($this->settings->api_key ?? '')) ->acceptJson() - ->timeout(60) + ->timeout(90) ->post("{$baseUrl}/chat/completions", $payload) ->throw() ->json(); @@ -104,4 +139,22 @@ class OpenAiCompatibleLlmProvider implements LlmProvider return $response; } + + /** + * @return array|null + */ + protected function decodeJsonObject(string $content): ?array + { + $trimmed = trim($content); + + if (preg_match('/```(?:json)?\s*(\{.*?\})\s*```/s', $trimmed, $matches) === 1) { + $trimmed = $matches[1]; + } elseif (preg_match('/\{.*\}/s', $trimmed, $matches) === 1) { + $trimmed = $matches[0]; + } + + $decoded = json_decode($trimmed, true); + + return is_array($decoded) ? $decoded : null; + } } diff --git a/app/Domain/Ai/StubLlmProvider.php b/app/Domain/Ai/StubLlmProvider.php index 89bbf36..5543b88 100644 --- a/app/Domain/Ai/StubLlmProvider.php +++ b/app/Domain/Ai/StubLlmProvider.php @@ -10,8 +10,12 @@ class StubLlmProvider implements LlmProvider { public function complete(string $prompt, array $context = []): array { + $title = trim((string) ($context['title'] ?? 'Untitled')); + return [ - 'summary' => 'Stub summary for content optimization.', + 'summary' => 'Stub summary for «'.$title.'».', + 'description' => 'Stub SEO description for '.$title.'.', + 'polished_content' => trim($prompt)."\n\n", 'suggestions' => [ 'Review headings for clarity.', 'Add a concise meta description.', diff --git a/app/Domain/Media/ArticleCoverGenerator.php b/app/Domain/Media/ArticleCoverGenerator.php new file mode 100644 index 0000000..1f2adb9 --- /dev/null +++ b/app/Domain/Media/ArticleCoverGenerator.php @@ -0,0 +1,167 @@ +format('Y/m'), + $article->id, + substr(sha1($article->title.'|'.microtime(true)), 0, 10), + ); + + $binary = $this->renderJpeg($article); + Storage::disk($disk)->put($relative, $binary, ['visibility' => 'public']); + + Attachment::query()->create([ + 'article_id' => $article->id, + 'disk' => $disk, + 'path' => $relative, + 'filename' => basename($relative), + 'mime' => 'image/jpeg', + 'size' => strlen($binary), + 'checksum' => hash('sha256', $binary), + 'visibility' => Attachment::VISIBILITY_PUBLIC, + 'synced_at' => now(), + ]); + + return [ + 'disk' => $disk, + 'path' => $relative, + 'source' => ArticleCoverService::SOURCE_GENERATED, + ]; + } + + protected function renderJpeg(Article $article): string + { + $manager = new ImageManager(new Driver); + $image = $manager->create(1200, 630)->fill('#0f3d4c'); + + // Soft bands for atmosphere (avoid a single flat fill). + $image->drawRectangle(0, 0, function ($rect): void { + $rect->size(1200, 210)->background('#123f4f'); + }); + $image->drawRectangle(0, 420, function ($rect): void { + $rect->size(1200, 210)->background('#16384a'); + }); + $image->drawRectangle(0, 0, function ($rect): void { + $rect->size(1200, 12)->background('#2bb0a6'); + }); + + $site = 'LaraBlog'; + try { + $site = (string) (app(GeneralSettings::class)->site_name ?: $site); + } catch (\Throwable) { + // + } + + $title = $this->wrapText((string) $article->title, 18, 3); + $summary = trim((string) ($article->ai_summary ?: $article->description ?: '')); + if ($summary !== '') { + $summary = $this->wrapText($summary, 32, 2); + } + + $font = $this->fontFile(); + + if ($font !== null) { + $image->text($site, 72, 110, function ($f) use ($font): void { + $f->filename($font); + $f->size(28); + $f->color('#8fd9d2'); + }); + + $image->text($title, 72, 220, function ($f) use ($font): void { + $f->filename($font); + $f->size(56); + $f->color('#f4f7f8'); + $f->lineHeight(1.25); + }); + + if ($summary !== '') { + $image->text($summary, 72, 430, function ($f) use ($font): void { + $f->filename($font); + $f->size(26); + $f->color('#c5d4db'); + $f->lineHeight(1.35); + }); + } + } + + $encoded = $image->toJpeg(85)->toString(); + + if (! is_string($encoded) || $encoded === '') { + throw new RuntimeException('Failed to encode generated cover JPEG.'); + } + + return $encoded; + } + + protected function wrapText(string $text, int $maxChars, int $maxLines): string + { + $text = trim(preg_replace('/\s+/u', ' ', $text) ?? ''); + + if ($text === '') { + return ''; + } + + $lines = []; + while ($text !== '' && count($lines) < $maxLines) { + if (mb_strlen($text) <= $maxChars) { + $lines[] = $text; + break; + } + + $chunk = mb_substr($text, 0, $maxChars); + $lines[] = $chunk; + $text = ltrim(mb_substr($text, $maxChars)); + } + + if ($text !== '' && $lines !== []) { + $last = $lines[array_key_last($lines)]; + $lines[array_key_last($lines)] = mb_substr($last, 0, max(1, $maxChars - 1)).'…'; + } + + return implode("\n", $lines); + } + + protected function fontFile(): ?string + { + $configured = config('larablog.cover_font'); + $candidates = array_filter([ + is_string($configured) && $configured !== '' ? $configured : null, + resource_path('fonts/Cover.ttf'), + resource_path('fonts/NotoSansSC-Regular.otf'), + '/System/Library/Fonts/Supplemental/Arial Unicode.ttf', + '/Library/Fonts/Arial Unicode.ttf', + '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', + '/usr/share/fonts/truetype/wqy/wqy-microhei.ttc', + '/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc', + ]); + + foreach ($candidates as $path) { + if (is_string($path) && is_file($path)) { + return $path; + } + } + + return null; + } +} diff --git a/app/Domain/Media/ArticleCoverService.php b/app/Domain/Media/ArticleCoverService.php new file mode 100644 index 0000000..fdaa1d6 --- /dev/null +++ b/app/Domain/Media/ArticleCoverService.php @@ -0,0 +1,182 @@ + $this->fromAttachments($article), + 'from_content', 'content_image' => $this->fromContent($article), + 'generate' => $this->generator->generate($article), + default => $this->fromContent($article) ?? $this->fromAttachments($article), + }; + } + + public function apply(Article $article, string $strategy = 'auto'): Article + { + $article->forceFill([ + 'cover_status' => self::STATUS_PENDING, + ])->save(); + + try { + $resolved = $this->resolve($article, $strategy); + } catch (\Throwable $exception) { + $article->forceFill([ + 'cover_status' => self::STATUS_FAILED, + 'cover_source' => $strategy === 'generate' ? self::SOURCE_GENERATED : self::SOURCE_NONE, + 'cover_generated_at' => now(), + ])->save(); + + throw $exception; + } + + if ($resolved === null) { + $article->forceFill([ + 'cover_disk' => null, + 'cover_path' => null, + 'cover_source' => self::SOURCE_NONE, + 'cover_status' => self::STATUS_FAILED, + 'cover_generated_at' => now(), + ])->save(); + + return $article->refresh(); + } + + $article->forceFill([ + 'cover_disk' => $resolved['disk'], + 'cover_path' => $resolved['path'], + 'cover_source' => $resolved['source'], + 'cover_status' => self::STATUS_READY, + 'cover_generated_at' => now(), + ])->save(); + + return $article->refresh(); + } + + public function publicUrl(Article $article): ?string + { + if (! $article->hasCover()) { + return null; + } + + $path = (string) $article->cover_path; + + if ($article->cover_disk === 'url' || str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) { + return $path; + } + + $disk = (string) ($article->cover_disk ?: config('larablog.attachments_disk', 'attachments')); + + try { + return Storage::disk($disk)->url($path); + } catch (\Throwable) { + return url('/'.$path); + } + } + + /** + * @return array{disk: ?string, path: string, source: string}|null + */ + protected function fromContent(Article $article): ?array + { + $content = (string) $article->content; + + if (preg_match('/!\[[^\]]*\]\(attach:(\d+)\)/i', $content, $matches) === 1 + || preg_match('/\[attach=(\d+)\]/i', $content, $matches) === 1) { + $attachment = Attachment::query()->find((int) $matches[1]); + if ($attachment !== null && $this->isImage($attachment)) { + return [ + 'disk' => $attachment->disk, + 'path' => $attachment->path, + 'source' => self::SOURCE_CONTENT_IMAGE, + ]; + } + } + + if (preg_match('/!\[[^\]]*\]\((https?:\/\/[^)\s]+)\)/i', $content, $matches) === 1) { + return [ + 'disk' => 'url', + 'path' => $matches[1], + 'source' => self::SOURCE_CONTENT_IMAGE, + ]; + } + + if (preg_match('/]+src=["\'](https?:\/\/[^"\']+)["\']/i', $content, $matches) === 1) { + return [ + 'disk' => 'url', + 'path' => $matches[1], + 'source' => self::SOURCE_CONTENT_IMAGE, + ]; + } + + return null; + } + + /** + * @return array{disk: ?string, path: string, source: string}|null + */ + protected function fromAttachments(Article $article): ?array + { + $attachment = $article->attachments() + ->orderBy('id') + ->get() + ->first(fn (Attachment $item): bool => $this->isImage($item)); + + if ($attachment === null) { + return null; + } + + return [ + 'disk' => $attachment->disk, + 'path' => $attachment->path, + 'source' => self::SOURCE_ATTACHMENT, + ]; + } + + protected function isImage(Attachment $attachment): bool + { + if (is_string($attachment->mime) && str_starts_with($attachment->mime, 'image/')) { + return true; + } + + $ext = strtolower(pathinfo((string) $attachment->filename, PATHINFO_EXTENSION)); + + return in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'], true); + } +} diff --git a/app/Domain/Plugin/PluginManager.php b/app/Domain/Plugin/PluginManager.php index d61e265..b3ff3bc 100644 --- a/app/Domain/Plugin/PluginManager.php +++ b/app/Domain/Plugin/PluginManager.php @@ -4,11 +4,13 @@ declare(strict_types=1); namespace App\Domain\Plugin; +use App\Domain\Blog\ContentRenderer; use App\Models\Plugin; use Illuminate\Support\Collection; use Illuminate\Support\Facades\File; use Illuminate\Support\ServiceProvider; use InvalidArgumentException; +use Mews\Purifier\Facades\Purifier; use RuntimeException; class PluginManager @@ -168,7 +170,7 @@ class PluginManager return $dependents; } - public function docsPath(string $name): ?string + public function docsPath(string $name, ?string $locale = null): ?string { $manifest = $this->discover()->get($name); if ($manifest === null) { @@ -181,25 +183,91 @@ class PluginManager } $root = realpath((string) $manifest['path']); - $path = realpath(rtrim((string) $manifest['path'], '/').'/'.$docs); - - if ($root === false || $path === false || ! is_file($path)) { + if ($root === false) { return null; } - // Never read outside the plugin directory, even via symlinks. - if ($path !== $root && ! str_starts_with($path, $root.DIRECTORY_SEPARATOR)) { - return null; + foreach ($this->docsCandidates($docs, $locale ?? app()->getLocale()) as $relative) { + if (str_starts_with($relative, '/') || preg_match('#(^|[\\\\/])\.\.([\\\\/]|$)#', $relative) === 1) { + continue; + } + + $path = realpath($root.DIRECTORY_SEPARATOR.str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $relative)); + if ($path === false || ! is_file($path)) { + continue; + } + + // Never read outside the plugin directory, even via symlinks. + if ($path !== $root && ! str_starts_with($path, $root.DIRECTORY_SEPARATOR)) { + continue; + } + + return $path; } - return $path; + return null; } - public function readDocs(string $name): ?string + public function readDocs(string $name, ?string $locale = null): ?string { - $path = $this->docsPath($name); + $path = $this->docsPath($name, $locale); + if ($path === null) { + return null; + } - return $path !== null ? (string) file_get_contents($path) : null; + $contents = (string) file_get_contents($path); + + return trim($contents) === '' ? null : $contents; + } + + public function readDocsHtml(string $name, ?string $locale = null): ?string + { + $markdown = $this->readDocs($name, $locale); + if ($markdown === null) { + return null; + } + + $html = app(ContentRenderer::class)->markdownToHtml($markdown); + + return Purifier::clean($html, 'article'); + } + + /** + * Prefer README.{locale}.md, then README.{lang}.md, then the manifest docs file. + * + * @return list + */ + protected function docsCandidates(string $docs, string $locale): array + { + $docs = str_replace('\\', '/', $docs); + $directory = dirname($docs); + $basename = basename($docs); + $extension = pathinfo($basename, PATHINFO_EXTENSION); + $stem = $extension !== '' + ? substr($basename, 0, -strlen($extension) - 1) + : $basename; + $suffix = $extension !== '' ? '.'.$extension : ''; + $prefix = ($directory === '.' || $directory === '') ? '' : $directory.'/'; + + $names = []; + $normalized = str_replace('-', '_', trim($locale)); + if ($normalized !== '' && preg_match('/^[A-Za-z0-9_]+$/', $normalized) === 1) { + $names[] = $stem.'.'.$normalized.$suffix; + if (str_contains($normalized, '_')) { + $names[] = $stem.'.'.explode('_', $normalized, 2)[0].$suffix; + } + } + $names[] = $basename; + + $candidates = []; + foreach ($names as $name) { + $relative = $prefix.$name; + if (! in_array($relative, $candidates, true)) { + $candidates[] = $relative; + } + } + + return $candidates; } public function registerEnabledProviders(): void diff --git a/app/Domain/Seo/SeoPresenter.php b/app/Domain/Seo/SeoPresenter.php index 8200339..58a5b8a 100644 --- a/app/Domain/Seo/SeoPresenter.php +++ b/app/Domain/Seo/SeoPresenter.php @@ -5,8 +5,10 @@ declare(strict_types=1); namespace App\Domain\Seo; use App\Models\Article; +use App\Models\Category; use App\Settings\GeneralSettings; use App\Settings\SeoSettings; +use Illuminate\Support\Str; class SeoPresenter { @@ -76,6 +78,62 @@ class SeoPresenter ]; } + /** + * @return array{title: string, description: string, keywords: ?string, canonical: string, og: array, twitter: array, jsonld: array} + */ + public function forCategory(Category $category): array + { + $canonical = $category->publicUrl(); + $rawDescription = $category->description ?: Str::limit(trim(preg_replace('/\s+/', ' ', (string) $category->intro) ?: ''), 160); + $description = $this->description($rawDescription !== '' ? $rawDescription : null); + + return [ + 'title' => $this->title($category->name), + 'description' => $description, + 'keywords' => $this->keywords($category->keywords), + 'canonical' => $canonical, + 'og' => array_filter([ + 'og:title' => $this->title($category->name), + 'og:description' => $description, + 'og:url' => $canonical, + 'og:type' => 'website', + 'og:site_name' => $this->generalSettings->site_name, + 'og:locale' => 'zh_CN', + 'og:image' => $category->coverUrl(), + ]), + 'twitter' => array_filter([ + 'twitter:card' => $category->coverUrl() ? 'summary_large_image' : 'summary', + 'twitter:title' => $this->title($category->name), + 'twitter:description' => $description, + 'twitter:image' => $category->coverUrl(), + ]), + 'jsonld' => $this->jsonLdForCategory($category, $canonical, $description), + ]; + } + + /** + * @return array + */ + protected function jsonLdForCategory(Category $category, string $canonical, string $description): array + { + if (! $this->seoSettings->json_ld_enabled) { + return []; + } + + return [ + '@context' => 'https://schema.org', + '@type' => 'CollectionPage', + 'name' => $category->name, + 'description' => $description, + 'url' => $canonical, + 'isPartOf' => [ + '@type' => 'WebSite', + 'name' => $this->generalSettings->site_name, + 'url' => $this->generalSettings->site_url ?: url('/'), + ], + ]; + } + /** * @return array */ @@ -94,6 +152,7 @@ class SeoPresenter 'og:type' => $article ? 'article' : 'website', 'og:site_name' => $this->generalSettings->site_name, 'og:locale' => 'zh_CN', + 'og:image' => $article?->coverUrl(), ]); } diff --git a/app/Filament/Pages/ManagePlugins.php b/app/Filament/Pages/ManagePlugins.php index 98ad022..549a9ec 100644 --- a/app/Filament/Pages/ManagePlugins.php +++ b/app/Filament/Pages/ManagePlugins.php @@ -10,8 +10,10 @@ use BackedEnum; use Filament\Actions\Action; use Filament\Notifications\Notification; use Filament\Pages\Page; +use Filament\Support\Enums\Alignment; +use Filament\Support\Enums\Width; use Filament\Support\Icons\Heroicon; -use UnitEnum; +use Illuminate\Contracts\View\View; class ManagePlugins extends Page { @@ -69,19 +71,45 @@ class ManagePlugins extends Page public function showDocs(string $name, PluginManager $manager): void { - $docs = $manager->readDocs($name); - if ($docs === null || trim($docs) === '') { + if ($manager->readDocs($name) === null) { Notification::make()->title(__('admin.messages.plugin_docs_missing'))->warning()->send(); return; } - Notification::make() - ->title(__('admin.pages.plugin_docs')) - ->body(str($docs)->limit(1800)->toString()) - ->persistent() - ->info() - ->send(); + $this->mountAction('viewDocs', ['name' => $name]); + } + + public function viewDocsAction(): Action + { + return Action::make('viewDocs') + ->label(__('admin.pages.plugin_docs')) + ->modalHeading(function (array $arguments): string { + $name = (string) ($arguments['name'] ?? ''); + $plugin = collect($this->plugins)->firstWhere('name', $name); + $title = is_array($plugin) ? (string) ($plugin['title'] ?? $name) : $name; + + return __('admin.pages.plugin_docs').($title !== '' ? ' · '.$title : ''); + }) + ->modalContent(function (array $arguments): View { + return view('filament.partials.plugin-docs', [ + 'html' => app(PluginManager::class)->readDocsHtml((string) ($arguments['name'] ?? '')) ?? '', + ]); + }) + ->modalAlignment(Alignment::Center) + ->modalWidth(Width::FiveExtraLarge) + ->modalSubmitAction(false) + ->modalCancelAction(fn (Action $action): Action => $action + ->label(__('admin.actions.close')) + ->color('primary') + ->close() + ) + ->modalFooterActionsAlignment(Alignment::End) + ->closeModalByClickingAway() + ->closeModalByEscaping() + ->stickyModalHeader() + ->stickyModalFooter() + ->extraModalWindowAttributes(['class' => 'lb-plugin-docs-modal']); } protected function reload(PluginManager $manager): void diff --git a/app/Filament/Pages/MembershipPluginPage.php b/app/Filament/Pages/MembershipPluginPage.php index 446bdc6..b081fab 100644 --- a/app/Filament/Pages/MembershipPluginPage.php +++ b/app/Filament/Pages/MembershipPluginPage.php @@ -22,4 +22,10 @@ class MembershipPluginPage extends PluginSkeletonPage { return 'membership'; } + + public static function shouldRegisterNavigation(): bool + { + // UI is owned by plugins/larablog/membership Filament resources. + return false; + } } diff --git a/app/Filament/Resources/Articles/ArticleResource.php b/app/Filament/Resources/Articles/ArticleResource.php index e8056de..22669c8 100644 --- a/app/Filament/Resources/Articles/ArticleResource.php +++ b/app/Filament/Resources/Articles/ArticleResource.php @@ -4,9 +4,11 @@ declare(strict_types=1); namespace App\Filament\Resources\Articles; +use App\Filament\Concerns\HasTranslatedLabels; use App\Filament\Resources\Articles\Pages\CreateArticle; use App\Filament\Resources\Articles\Pages\EditArticle; use App\Filament\Resources\Articles\Pages\ListArticles; +use App\Filament\Resources\Articles\RelationManagers\CommentsRelationManager; use App\Filament\Resources\Articles\Schemas\ArticleForm; use App\Filament\Resources\Articles\Tables\ArticlesTable; use App\Models\Article; @@ -15,8 +17,6 @@ use Filament\Resources\Resource; use Filament\Schemas\Schema; use Filament\Support\Icons\Heroicon; use Filament\Tables\Table; -use UnitEnum; -use App\Filament\Concerns\HasTranslatedLabels; class ArticleResource extends Resource { @@ -24,9 +24,6 @@ class ArticleResource extends Resource protected static ?string $model = Article::class; - - - protected static function navKey(): string { return 'articles'; @@ -57,7 +54,7 @@ class ArticleResource extends Resource public static function getRelations(): array { return [ - // + CommentsRelationManager::class, ]; } diff --git a/app/Filament/Resources/Articles/Pages/CreateArticle.php b/app/Filament/Resources/Articles/Pages/CreateArticle.php index 0a9d4b1..a1865cd 100644 --- a/app/Filament/Resources/Articles/Pages/CreateArticle.php +++ b/app/Filament/Resources/Articles/Pages/CreateArticle.php @@ -19,8 +19,14 @@ class CreateArticle extends CreateRecord protected function mutateFormDataBeforeCreate(array $data): array { $filtered = Hook::filter('filament.article.mutate_before_save', $data, null); + $data = is_array($filtered) ? $filtered : $data; - return is_array($filtered) ? $filtered : $data; + $validated = Hook::filter('filament.article.validate_access_restrictions', $data, null); + $data = is_array($validated) ? $validated : $data; + + unset($data['paid_content'], $data['membership']); + + return $data; } protected function afterCreate(): void diff --git a/app/Filament/Resources/Articles/Pages/EditArticle.php b/app/Filament/Resources/Articles/Pages/EditArticle.php index 23908d2..b2ffd90 100644 --- a/app/Filament/Resources/Articles/Pages/EditArticle.php +++ b/app/Filament/Resources/Articles/Pages/EditArticle.php @@ -4,9 +4,11 @@ declare(strict_types=1); namespace App\Filament\Resources\Articles\Pages; +use App\Domain\Ai\Jobs\GenerateArticleCoverJob; use App\Domain\Ai\Jobs\OptimizeArticleContentJob; use App\Domain\Plugin\Hook; use App\Filament\Resources\Articles\ArticleResource; +use App\Settings\AiSettings; use Filament\Actions\Action; use Filament\Actions\DeleteAction; use Filament\Notifications\Notification; @@ -21,6 +23,13 @@ class EditArticle extends EditRecord return [ Action::make('aiOptimize') ->label(__('admin.messages.ai_optimize')) + ->visible(function (): bool { + try { + return (bool) app(AiSettings::class)->content_optimization_enabled; + } catch (\Throwable) { + return false; + } + }) ->action(function (): void { OptimizeArticleContentJob::dispatch($this->record->getKey()); Notification::make() @@ -29,6 +38,55 @@ class EditArticle extends EditRecord ->success() ->send(); }), + Action::make('applyAiPolish') + ->label(__('admin.messages.ai_apply_polish')) + ->color('gray') + ->requiresConfirmation() + ->modalHeading(__('admin.messages.ai_apply_polish')) + ->modalDescription(__('admin.messages.ai_apply_polish_confirm')) + ->visible(fn (): bool => filled($this->record->ai_polished_content)) + ->action(function (): void { + $polished = (string) $this->record->ai_polished_content; + + if ($polished === '') { + Notification::make() + ->title(__('admin.messages.ai_polish_missing')) + ->danger() + ->send(); + + return; + } + + $this->record->forceFill(['content' => $polished])->save(); + $this->refreshFormData(['content', 'ai_polished_content', 'ai_summary', 'description']); + + Notification::make() + ->title(__('admin.messages.ai_apply_polish_done')) + ->success() + ->send(); + }), + Action::make('autoCover') + ->label(__('admin.messages.auto_cover')) + ->color('gray') + ->action(function (): void { + GenerateArticleCoverJob::dispatch($this->record->getKey(), 'auto'); + Notification::make() + ->title(__('admin.messages.auto_cover_queued')) + ->body(__('admin.messages.ai_optimize_queue_hint')) + ->success() + ->send(); + }), + Action::make('generateCover') + ->label(__('admin.messages.generate_cover')) + ->color('gray') + ->action(function (): void { + GenerateArticleCoverJob::dispatch($this->record->getKey(), 'generate'); + Notification::make() + ->title(__('admin.messages.generate_cover_queued')) + ->body(__('admin.messages.ai_optimize_queue_hint')) + ->success() + ->send(); + }), DeleteAction::make(), ...Hook::collect('filament.article.actions'), ]; @@ -40,6 +98,13 @@ class EditArticle extends EditRecord */ protected function mutateFormDataBeforeFill(array $data): array { + $items = is_array($this->record->ai_suggestions) ? $this->record->ai_suggestions : []; + $data['ai_suggestions_text'] = collect($items) + ->filter(fn ($item) => filled($item)) + ->values() + ->map(fn ($item, $index) => ($index + 1).'. '.$item) + ->implode("\n"); + $filtered = Hook::filter('filament.article.mutate_before_fill', $data, $this->record); return is_array($filtered) ? $filtered : $data; @@ -51,9 +116,26 @@ class EditArticle extends EditRecord */ protected function mutateFormDataBeforeSave(array $data): array { - $filtered = Hook::filter('filament.article.mutate_before_save', $data, $this->record); + if (filled($data['cover_path'] ?? null)) { + $path = (string) $data['cover_path']; + $data['cover_status'] = 'ready'; + $data['cover_source'] = str_starts_with($path, 'http://') || str_starts_with($path, 'https://') + ? 'content_image' + : 'manual'; + $data['cover_disk'] = ($data['cover_source'] === 'content_image') + ? 'url' + : ($this->record->cover_disk ?: config('larablog.attachments_disk', 'attachments')); + } - return is_array($filtered) ? $filtered : $data; + $filtered = Hook::filter('filament.article.mutate_before_save', $data, $this->record); + $data = is_array($filtered) ? $filtered : $data; + + $validated = Hook::filter('filament.article.validate_access_restrictions', $data, $this->record); + $data = is_array($validated) ? $validated : $data; + + unset($data['paid_content'], $data['membership']); + + return $data; } protected function afterSave(): void diff --git a/app/Filament/Resources/Articles/Pages/ListArticles.php b/app/Filament/Resources/Articles/Pages/ListArticles.php index 1c4ce23..2d41740 100644 --- a/app/Filament/Resources/Articles/Pages/ListArticles.php +++ b/app/Filament/Resources/Articles/Pages/ListArticles.php @@ -5,8 +5,8 @@ declare(strict_types=1); namespace App\Filament\Resources\Articles\Pages; use App\Filament\Resources\Articles\ArticleResource; +use App\Filament\Resources\Pages\ListRecords; use Filament\Actions\CreateAction; -use Filament\Resources\Pages\ListRecords; class ListArticles extends ListRecords { diff --git a/app/Filament/Resources/Articles/RelationManagers/CommentsRelationManager.php b/app/Filament/Resources/Articles/RelationManagers/CommentsRelationManager.php new file mode 100644 index 0000000..f5b6f8a --- /dev/null +++ b/app/Filament/Resources/Articles/RelationManagers/CommentsRelationManager.php @@ -0,0 +1,125 @@ +components([ + TextInput::make('author') + ->label(__('admin.fields.author')) + ->required(), + TextInput::make('url') + ->label(__('admin.fields.url')) + ->url(), + Textarea::make('content') + ->label(__('admin.fields.content')) + ->required() + ->columnSpanFull(), + Select::make('moderation_status') + ->label(__('admin.fields.moderation_status')) + ->options([ + Comment::STATUS_PENDING => __('admin.options.moderation.pending'), + Comment::STATUS_PENDING_AI => __('admin.options.moderation.pending_ai'), + Comment::STATUS_APPROVED => __('admin.options.moderation.approved'), + Comment::STATUS_REJECTED => __('admin.options.moderation.rejected'), + Comment::STATUS_NEEDS_HUMAN => __('admin.options.moderation.needs_human'), + ]) + ->required() + ->default(Comment::STATUS_PENDING), + DateTimePicker::make('published_at') + ->label(__('admin.fields.published_at')), + ]); + } + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('author') + ->columns([ + TextColumn::make('author') + ->label(__('admin.fields.author')) + ->searchable(), + AdminTable::ellipsis( + TextColumn::make('content') + ->label(__('admin.fields.content')) + ->html() + ->formatStateUsing(fn (?string $state): string => trim(html_entity_decode(strip_tags((string) $state), ENT_QUOTES | ENT_HTML5, 'UTF-8'))), + ), + TextColumn::make('moderation_status') + ->label(__('admin.fields.moderation_status')) + ->badge(), + TextColumn::make('published_at') + ->label(__('admin.fields.published_at')) + ->dateTime() + ->sortable(), + ]) + ->filters([ + SelectFilter::make('moderation_status') + ->label(__('admin.fields.moderation_status')) + ->options([ + Comment::STATUS_PENDING => __('admin.options.moderation.pending'), + Comment::STATUS_PENDING_AI => __('admin.options.moderation.pending_ai'), + Comment::STATUS_APPROVED => __('admin.options.moderation.approved'), + Comment::STATUS_REJECTED => __('admin.options.moderation.rejected'), + Comment::STATUS_NEEDS_HUMAN => __('admin.options.moderation.needs_human'), + ]), + ]) + ->headerActions([ + CreateAction::make() + ->mutateFormDataUsing(function (array $data): array { + $data['published_at'] ??= now(); + $data['moderation_status'] ??= Comment::STATUS_PENDING; + + return $data; + }) + ->after(function (): void { + $this->getOwnerRecord()->increment('comments_count'); + }), + ]) + ->recordActions([ + EditAction::make(), + DeleteAction::make() + ->after(function (): void { + $this->getOwnerRecord()->decrement('comments_count'); + }), + ]) + ->toolbarActions([ + DeleteBulkAction::make() + ->after(function (): void { + $this->getOwnerRecord()->forceFill([ + 'comments_count' => $this->getOwnerRecord()->comments()->count(), + ])->save(); + }), + ]) + ->defaultSort('published_at', 'desc'); + } +} diff --git a/app/Filament/Resources/Articles/Schemas/ArticleForm.php b/app/Filament/Resources/Articles/Schemas/ArticleForm.php index 8a160b6..65fa2e6 100644 --- a/app/Filament/Resources/Articles/Schemas/ArticleForm.php +++ b/app/Filament/Resources/Articles/Schemas/ArticleForm.php @@ -9,8 +9,8 @@ use App\Domain\Plugin\Hook; use App\Settings\GeneralSettings; use Filament\Forms\Components\DateTimePicker; use Filament\Forms\Components\Select; -use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Textarea; +use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Schema; @@ -78,8 +78,33 @@ class ArticleForm TextInput::make('read_password') ->label(__('admin.fields.read_password')) ->password(), + TextInput::make('cover_path') + ->label(__('admin.fields.cover_path')) + ->helperText(__('admin.helpers.cover_path')) + ->columnSpanFull(), + TextInput::make('cover_source') + ->label(__('admin.fields.cover_source')) + ->disabled() + ->dehydrated(false), + TextInput::make('cover_status') + ->label(__('admin.fields.cover_status')) + ->disabled() + ->dehydrated(false), Textarea::make('ai_summary') ->label(__('admin.fields.ai_summary')) + ->rows(3) + ->columnSpanFull(), + Textarea::make('ai_polished_content') + ->label(__('admin.fields.ai_polished_content')) + ->helperText(__('admin.helpers.ai_polished_content')) + ->rows(12) + ->columnSpanFull(), + Textarea::make('ai_suggestions_text') + ->label(__('admin.fields.ai_suggestions')) + ->helperText(__('admin.helpers.ai_suggestions')) + ->rows(4) + ->disabled() + ->dehydrated(false) ->columnSpanFull(), ...Hook::collect('filament.article.form'), ]); diff --git a/app/Filament/Resources/Articles/Tables/ArticlesTable.php b/app/Filament/Resources/Articles/Tables/ArticlesTable.php index 416c5c2..453475a 100644 --- a/app/Filament/Resources/Articles/Tables/ArticlesTable.php +++ b/app/Filament/Resources/Articles/Tables/ArticlesTable.php @@ -5,12 +5,16 @@ declare(strict_types=1); namespace App\Filament\Resources\Articles\Tables; use App\Domain\Plugin\Hook; +use App\Filament\Support\AdminTable; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Filters\SelectFilter; +use Filament\Tables\Filters\TernaryFilter; use Filament\Tables\Table; +use Illuminate\Database\Eloquent\Builder; class ArticlesTable { @@ -18,21 +22,32 @@ class ArticlesTable { return $table ->columns([ + AdminTable::stickyStart( + TextColumn::make('id') + ->label(__('admin.fields.id')) + ->sortable(), + ), TextColumn::make('category.name') ->label(__('admin.fields.category')) ->searchable(), TextColumn::make('user.name') ->label(__('admin.fields.author')) ->searchable(), - TextColumn::make('title') - ->label(__('admin.fields.title')) - ->searchable(), - TextColumn::make('description') - ->label(__('admin.fields.description')) - ->searchable(), + AdminTable::ellipsis( + TextColumn::make('title') + ->label(__('admin.fields.title')) + ->searchable(), + ), + AdminTable::ellipsis( + TextColumn::make('description') + ->label(__('admin.fields.description')) + ->searchable() + ->toggleable(isToggledHiddenByDefault: true), + ), TextColumn::make('keywords') ->label(__('admin.fields.keywords')) - ->searchable(), + ->searchable() + ->toggleable(isToggledHiddenByDefault: true), TextColumn::make('published_at') ->label(__('admin.fields.published_at')) ->dateTime() @@ -66,15 +81,33 @@ class ArticlesTable ->toggleable(isToggledHiddenByDefault: true), TextColumn::make('slug') ->label(__('admin.fields.slug')) - ->searchable(), + ->searchable() + ->toggleable(isToggledHiddenByDefault: true), TextColumn::make('content_format') ->label(__('admin.fields.content_format')) - ->searchable(), + ->searchable() + ->toggleable(isToggledHiddenByDefault: true), ...Hook::collect('filament.article.table.columns'), ]) ->filters([ - // + SelectFilter::make('category_id') + ->label(__('admin.fields.category')) + ->relationship('category', 'name') + ->searchable() + ->preload(), + TernaryFilter::make('visible') + ->label(__('admin.fields.visible')), + TernaryFilter::make('stick') + ->label(__('admin.fields.stick')), + TernaryFilter::make('close_comment') + ->label(__('admin.fields.close_comment')), + ...Hook::collect('filament.article.table.filters'), ]) + ->modifyQueryUsing(function (Builder $query): Builder { + $modified = Hook::filter('filament.article.table.query', $query); + + return $modified instanceof Builder ? $modified : $query; + }) ->recordActions([ EditAction::make(), ...Hook::collect('filament.article.actions'), diff --git a/app/Filament/Resources/Attachments/Pages/ListAttachments.php b/app/Filament/Resources/Attachments/Pages/ListAttachments.php index 58679f0..9091273 100644 --- a/app/Filament/Resources/Attachments/Pages/ListAttachments.php +++ b/app/Filament/Resources/Attachments/Pages/ListAttachments.php @@ -5,8 +5,8 @@ declare(strict_types=1); namespace App\Filament\Resources\Attachments\Pages; use App\Filament\Resources\Attachments\AttachmentResource; +use App\Filament\Resources\Pages\ListRecords; use Filament\Actions\CreateAction; -use Filament\Resources\Pages\ListRecords; class ListAttachments extends ListRecords { diff --git a/app/Filament/Resources/Attachments/Tables/AttachmentsTable.php b/app/Filament/Resources/Attachments/Tables/AttachmentsTable.php index d011c36..d6a381a 100644 --- a/app/Filament/Resources/Attachments/Tables/AttachmentsTable.php +++ b/app/Filament/Resources/Attachments/Tables/AttachmentsTable.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Filament\Resources\Attachments\Tables; +use App\Filament\Support\AdminTable; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; @@ -16,21 +17,28 @@ class AttachmentsTable { return $table ->columns([ - TextColumn::make('article.title') - ->label(__('admin.fields.article')) - ->searchable(), + AdminTable::ellipsis( + TextColumn::make('article.title') + ->label(__('admin.fields.article')) + ->searchable(), + ), TextColumn::make('disk') ->label(__('admin.fields.disk')) ->searchable(), - TextColumn::make('path') - ->label(__('admin.fields.path')) - ->searchable(), + AdminTable::ellipsis( + TextColumn::make('path') + ->label(__('admin.fields.path')) + ->searchable(), + ), TextColumn::make('thumb_path') ->label(__('admin.fields.thumb_path')) - ->searchable(), - TextColumn::make('filename') - ->label(__('admin.fields.filename')) - ->searchable(), + ->searchable() + ->toggleable(isToggledHiddenByDefault: true), + AdminTable::ellipsis( + TextColumn::make('filename') + ->label(__('admin.fields.filename')) + ->searchable(), + ), TextColumn::make('mime') ->label(__('admin.fields.mime')) ->searchable(), diff --git a/app/Filament/Resources/Categories/Pages/ListCategories.php b/app/Filament/Resources/Categories/Pages/ListCategories.php index 746c1bd..2077a3b 100644 --- a/app/Filament/Resources/Categories/Pages/ListCategories.php +++ b/app/Filament/Resources/Categories/Pages/ListCategories.php @@ -5,8 +5,8 @@ declare(strict_types=1); namespace App\Filament\Resources\Categories\Pages; use App\Filament\Resources\Categories\CategoryResource; +use App\Filament\Resources\Pages\ListRecords; use Filament\Actions\CreateAction; -use Filament\Resources\Pages\ListRecords; class ListCategories extends ListRecords { diff --git a/app/Filament/Resources/Categories/Schemas/CategoryForm.php b/app/Filament/Resources/Categories/Schemas/CategoryForm.php index 6bffde8..35e2d2e 100644 --- a/app/Filament/Resources/Categories/Schemas/CategoryForm.php +++ b/app/Filament/Resources/Categories/Schemas/CategoryForm.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Filament\Resources\Categories\Schemas; +use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Schemas\Schema; @@ -16,16 +17,25 @@ class CategoryForm TextInput::make('name') ->label(__('admin.fields.name')) ->required(), + TextInput::make('description') + ->label(__('admin.fields.description')) + ->maxLength(255) + ->helperText(__('admin.helpers.category_description')), + Textarea::make('intro') + ->label(__('admin.fields.intro')) + ->rows(5) + ->columnSpanFull() + ->helperText(__('admin.helpers.category_intro')), + TextInput::make('keywords') + ->label(__('admin.fields.keywords')), + TextInput::make('cover_path') + ->label(__('admin.fields.cover_path')) + ->helperText(__('admin.helpers.category_cover')), TextInput::make('display_order') ->label(__('admin.fields.display_order')) ->required() ->numeric() ->default(0), - TextInput::make('articles_count') - ->label(__('admin.fields.articles_count')) - ->required() - ->numeric() - ->default(0), ]); } } diff --git a/app/Filament/Resources/Categories/Tables/CategoriesTable.php b/app/Filament/Resources/Categories/Tables/CategoriesTable.php index acf429d..05ef3d8 100644 --- a/app/Filament/Resources/Categories/Tables/CategoriesTable.php +++ b/app/Filament/Resources/Categories/Tables/CategoriesTable.php @@ -4,6 +4,9 @@ declare(strict_types=1); namespace App\Filament\Resources\Categories\Tables; +use App\Filament\Resources\Articles\ArticleResource; +use App\Filament\Support\AdminTable; +use App\Models\Category; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; @@ -16,9 +19,11 @@ class CategoriesTable { return $table ->columns([ - TextColumn::make('name') - ->label(__('admin.fields.name')) - ->searchable(), + AdminTable::ellipsis( + TextColumn::make('name') + ->label(__('admin.fields.name')) + ->searchable(), + ), TextColumn::make('display_order') ->label(__('admin.fields.display_order')) ->numeric() @@ -26,7 +31,17 @@ class CategoriesTable TextColumn::make('articles_count') ->label(__('admin.fields.articles_count')) ->numeric() - ->sortable(), + ->sortable() + ->url(fn (Category $record): string => ArticleResource::getUrl('index', [ + 'filters' => [ + 'category_id' => ['value' => $record->id], + ], + ])), + AdminTable::ellipsis( + TextColumn::make('description') + ->label(__('admin.fields.description')) + ->toggleable(), + ), TextColumn::make('created_at') ->label(__('admin.fields.created_at')) ->dateTime() diff --git a/app/Filament/Resources/Comments/Pages/ListComments.php b/app/Filament/Resources/Comments/Pages/ListComments.php index 21c3684..d726330 100644 --- a/app/Filament/Resources/Comments/Pages/ListComments.php +++ b/app/Filament/Resources/Comments/Pages/ListComments.php @@ -5,8 +5,8 @@ declare(strict_types=1); namespace App\Filament\Resources\Comments\Pages; use App\Filament\Resources\Comments\CommentResource; +use App\Filament\Resources\Pages\ListRecords; use Filament\Actions\CreateAction; -use Filament\Resources\Pages\ListRecords; class ListComments extends ListRecords { diff --git a/app/Filament/Resources/Comments/Schemas/CommentForm.php b/app/Filament/Resources/Comments/Schemas/CommentForm.php index 06ea3d2..bbce569 100644 --- a/app/Filament/Resources/Comments/Schemas/CommentForm.php +++ b/app/Filament/Resources/Comments/Schemas/CommentForm.php @@ -4,11 +4,12 @@ declare(strict_types=1); namespace App\Filament\Resources\Comments\Schemas; +use App\Domain\Blog\ArticleExcerpt; use App\Models\Comment; use Filament\Forms\Components\DateTimePicker; use Filament\Forms\Components\Select; -use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Textarea; +use Filament\Forms\Components\TextInput; use Filament\Schemas\Schema; class CommentForm @@ -20,7 +21,22 @@ class CommentForm Select::make('article_id') ->label(__('admin.fields.article')) ->relationship('article', 'title') - ->required(), + ->required() + ->live(), + Textarea::make('article_excerpt_preview') + ->label(__('admin.fields.article_excerpt')) + ->disabled() + ->dehydrated(false) + ->rows(3) + ->columnSpanFull() + ->visible(fn (?Comment $record): bool => $record?->article !== null) + ->afterStateHydrated(function (Textarea $component, mixed $state, ?Comment $record): void { + if ($record?->article === null) { + return; + } + + $component->state(app(ArticleExcerpt::class)->forList($record->article, 240)); + }), TextInput::make('author') ->label(__('admin.fields.author')) ->required(), diff --git a/app/Filament/Resources/Comments/Tables/CommentsTable.php b/app/Filament/Resources/Comments/Tables/CommentsTable.php index b97ca3e..cf1615c 100644 --- a/app/Filament/Resources/Comments/Tables/CommentsTable.php +++ b/app/Filament/Resources/Comments/Tables/CommentsTable.php @@ -4,10 +4,14 @@ declare(strict_types=1); namespace App\Filament\Resources\Comments\Tables; +use App\Domain\Blog\ArticleExcerpt; +use App\Filament\Support\AdminTable; +use App\Models\Comment; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Table; class CommentsTable @@ -15,16 +19,32 @@ class CommentsTable public static function configure(Table $table): Table { return $table + ->modifyQueryUsing(fn ($query) => $query->with('article')) ->columns([ - TextColumn::make('article.title') - ->label(__('admin.fields.article')) - ->searchable(), + AdminTable::ellipsis( + TextColumn::make('article.title') + ->label(__('admin.fields.article')) + ->searchable(), + ), + AdminTable::ellipsis( + TextColumn::make('article_excerpt') + ->label(__('admin.fields.article_excerpt')) + ->getStateUsing(function (Comment $record): string { + if ($record->article === null) { + return ''; + } + + return app(ArticleExcerpt::class)->forList($record->article, 80); + }), + ), TextColumn::make('author') ->label(__('admin.fields.author')) ->searchable(), - TextColumn::make('url') - ->label(__('admin.fields.url')) - ->searchable(), + AdminTable::ellipsis( + TextColumn::make('url') + ->label(__('admin.fields.url')) + ->searchable(), + ), TextColumn::make('ip') ->label(__('admin.fields.ip')) ->searchable(), @@ -47,7 +67,15 @@ class CommentsTable ->toggleable(isToggledHiddenByDefault: true), ]) ->filters([ - // + SelectFilter::make('moderation_status') + ->label(__('admin.fields.moderation_status')) + ->options([ + Comment::STATUS_PENDING => __('admin.options.moderation.pending'), + Comment::STATUS_PENDING_AI => __('admin.options.moderation.pending_ai'), + Comment::STATUS_APPROVED => __('admin.options.moderation.approved'), + Comment::STATUS_REJECTED => __('admin.options.moderation.rejected'), + Comment::STATUS_NEEDS_HUMAN => __('admin.options.moderation.needs_human'), + ]), ]) ->recordActions([ EditAction::make(), diff --git a/app/Filament/Resources/Links/Pages/ListLinks.php b/app/Filament/Resources/Links/Pages/ListLinks.php index 4e6c1f4..8f77fb5 100644 --- a/app/Filament/Resources/Links/Pages/ListLinks.php +++ b/app/Filament/Resources/Links/Pages/ListLinks.php @@ -5,8 +5,8 @@ declare(strict_types=1); namespace App\Filament\Resources\Links\Pages; use App\Filament\Resources\Links\LinkResource; +use App\Filament\Resources\Pages\ListRecords; use Filament\Actions\CreateAction; -use Filament\Resources\Pages\ListRecords; class ListLinks extends ListRecords { diff --git a/app/Filament/Resources/Links/Tables/LinksTable.php b/app/Filament/Resources/Links/Tables/LinksTable.php index f3429c4..467d6f4 100644 --- a/app/Filament/Resources/Links/Tables/LinksTable.php +++ b/app/Filament/Resources/Links/Tables/LinksTable.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Filament\Resources\Links\Tables; +use App\Filament\Support\AdminTable; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; @@ -17,12 +18,16 @@ class LinksTable { return $table ->columns([ - TextColumn::make('name') - ->label(__('admin.fields.name')) - ->searchable(), - TextColumn::make('url') - ->label(__('admin.fields.url')) - ->searchable(), + AdminTable::ellipsis( + TextColumn::make('name') + ->label(__('admin.fields.name')) + ->searchable(), + ), + AdminTable::ellipsis( + TextColumn::make('url') + ->label(__('admin.fields.url')) + ->searchable(), + ), TextColumn::make('display_order') ->label(__('admin.fields.display_order')) ->numeric() diff --git a/app/Filament/Resources/Pages/ListRecords.php b/app/Filament/Resources/Pages/ListRecords.php new file mode 100644 index 0000000..d043adb --- /dev/null +++ b/app/Filament/Resources/Pages/ListRecords.php @@ -0,0 +1,21 @@ +recordUrl(null) + ->recordAction(null); + } +} diff --git a/app/Filament/Resources/Plugins/Pages/ListPlugins.php b/app/Filament/Resources/Plugins/Pages/ListPlugins.php index 892f095..4fa80fb 100644 --- a/app/Filament/Resources/Plugins/Pages/ListPlugins.php +++ b/app/Filament/Resources/Plugins/Pages/ListPlugins.php @@ -4,9 +4,9 @@ declare(strict_types=1); namespace App\Filament\Resources\Plugins\Pages; +use App\Filament\Resources\Pages\ListRecords; use App\Filament\Resources\Plugins\PluginResource; use Filament\Actions\CreateAction; -use Filament\Resources\Pages\ListRecords; class ListPlugins extends ListRecords { diff --git a/app/Filament/Resources/Plugins/Tables/PluginsTable.php b/app/Filament/Resources/Plugins/Tables/PluginsTable.php index 156a222..35f1e59 100644 --- a/app/Filament/Resources/Plugins/Tables/PluginsTable.php +++ b/app/Filament/Resources/Plugins/Tables/PluginsTable.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Filament\Resources\Plugins\Tables; +use App\Filament\Support\AdminTable; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; @@ -17,9 +18,11 @@ class PluginsTable { return $table ->columns([ - TextColumn::make('name') - ->label(__('admin.fields.name')) - ->searchable(), + AdminTable::ellipsis( + TextColumn::make('name') + ->label(__('admin.fields.name')) + ->searchable(), + ), TextColumn::make('version') ->label(__('admin.fields.version')) ->searchable(), diff --git a/app/Filament/Resources/Stylevars/Pages/ListStylevars.php b/app/Filament/Resources/Stylevars/Pages/ListStylevars.php index 1c8bef7..fa59307 100644 --- a/app/Filament/Resources/Stylevars/Pages/ListStylevars.php +++ b/app/Filament/Resources/Stylevars/Pages/ListStylevars.php @@ -4,9 +4,9 @@ declare(strict_types=1); namespace App\Filament\Resources\Stylevars\Pages; +use App\Filament\Resources\Pages\ListRecords; use App\Filament\Resources\Stylevars\StylevarResource; use Filament\Actions\CreateAction; -use Filament\Resources\Pages\ListRecords; class ListStylevars extends ListRecords { diff --git a/app/Filament/Resources/Stylevars/Tables/StylevarsTable.php b/app/Filament/Resources/Stylevars/Tables/StylevarsTable.php index cf763a0..a86fda0 100644 --- a/app/Filament/Resources/Stylevars/Tables/StylevarsTable.php +++ b/app/Filament/Resources/Stylevars/Tables/StylevarsTable.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Filament\Resources\Stylevars\Tables; +use App\Filament\Support\AdminTable; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; @@ -17,16 +18,21 @@ class StylevarsTable { return $table ->columns([ - TextColumn::make('id') - ->label(__('admin.fields.id')) - ->sortable(), - TextColumn::make('title') - ->label(__('admin.fields.title')) - ->searchable() - ->sortable(), - TextColumn::make('value') - ->label(__('admin.fields.value')) - ->limit(60), + AdminTable::stickyStart( + TextColumn::make('id') + ->label(__('admin.fields.id')) + ->sortable(), + ), + AdminTable::ellipsis( + TextColumn::make('title') + ->label(__('admin.fields.title')) + ->searchable() + ->sortable(), + ), + AdminTable::ellipsis( + TextColumn::make('value') + ->label(__('admin.fields.value')), + ), IconColumn::make('visible') ->label(__('admin.fields.visible')) ->boolean(), diff --git a/app/Filament/Resources/Tags/Pages/ListTags.php b/app/Filament/Resources/Tags/Pages/ListTags.php index a3c57cd..eec76e0 100644 --- a/app/Filament/Resources/Tags/Pages/ListTags.php +++ b/app/Filament/Resources/Tags/Pages/ListTags.php @@ -4,9 +4,9 @@ declare(strict_types=1); namespace App\Filament\Resources\Tags\Pages; +use App\Filament\Resources\Pages\ListRecords; use App\Filament\Resources\Tags\TagResource; use Filament\Actions\CreateAction; -use Filament\Resources\Pages\ListRecords; class ListTags extends ListRecords { diff --git a/app/Filament/Resources/Tags/Tables/TagsTable.php b/app/Filament/Resources/Tags/Tables/TagsTable.php index 803a29a..357b997 100644 --- a/app/Filament/Resources/Tags/Tables/TagsTable.php +++ b/app/Filament/Resources/Tags/Tables/TagsTable.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Filament\Resources\Tags\Tables; +use App\Filament\Support\AdminTable; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; @@ -16,9 +17,11 @@ class TagsTable { return $table ->columns([ - TextColumn::make('name') - ->label(__('admin.fields.name')) - ->searchable(), + AdminTable::ellipsis( + TextColumn::make('name') + ->label(__('admin.fields.name')) + ->searchable(), + ), TextColumn::make('use_count') ->label(__('admin.fields.use_count')) ->numeric() diff --git a/app/Filament/Resources/Users/Pages/ListUsers.php b/app/Filament/Resources/Users/Pages/ListUsers.php index 441d454..3390533 100644 --- a/app/Filament/Resources/Users/Pages/ListUsers.php +++ b/app/Filament/Resources/Users/Pages/ListUsers.php @@ -4,9 +4,9 @@ declare(strict_types=1); namespace App\Filament\Resources\Users\Pages; +use App\Filament\Resources\Pages\ListRecords; use App\Filament\Resources\Users\UserResource; use Filament\Actions\CreateAction; -use Filament\Resources\Pages\ListRecords; class ListUsers extends ListRecords { diff --git a/app/Filament/Resources/Users/Tables/UsersTable.php b/app/Filament/Resources/Users/Tables/UsersTable.php index ade08a1..7939816 100644 --- a/app/Filament/Resources/Users/Tables/UsersTable.php +++ b/app/Filament/Resources/Users/Tables/UsersTable.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Filament\Resources\Users\Tables; +use App\Filament\Support\AdminTable; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; @@ -16,19 +17,25 @@ class UsersTable { return $table ->columns([ - TextColumn::make('id') - ->label(__('admin.fields.id')) - ->sortable(), + AdminTable::stickyStart( + TextColumn::make('id') + ->label(__('admin.fields.id')) + ->sortable(), + ), TextColumn::make('username') ->label(__('admin.fields.username')) ->searchable() ->sortable(), - TextColumn::make('name') - ->label(__('admin.fields.display_name')) - ->searchable(), - TextColumn::make('email') - ->label(__('admin.fields.email')) - ->searchable(), + AdminTable::ellipsis( + TextColumn::make('name') + ->label(__('admin.fields.display_name')) + ->searchable(), + ), + AdminTable::ellipsis( + TextColumn::make('email') + ->label(__('admin.fields.email')) + ->searchable(), + ), TextColumn::make('roles.name') ->badge() ->label(__('admin.fields.roles')), diff --git a/app/Filament/Support/AdminTable.php b/app/Filament/Support/AdminTable.php new file mode 100644 index 0000000..f7f3753 --- /dev/null +++ b/app/Filament/Support/AdminTable.php @@ -0,0 +1,58 @@ +filtersLayout(FiltersLayout::AboveContent) + ->filtersResetActionPosition(FiltersResetActionPosition::Footer) + ->filtersFormSchema(function (array $filters): array { + return collect($filters) + ->map(fn ($group) => $group->inlineLabel()) + ->values() + ->all(); + }) + ->extraAttributes(['class' => 'lb-admin-table'], merge: true); + } + + /** + * Opt-in sticky column (typically id), pinned after the selection checkbox. + */ + public static function stickyStart(Column $column): Column + { + return $column + ->extraHeaderAttributes(['class' => 'lb-sticky-col-start'], merge: true) + ->extraCellAttributes(['class' => 'lb-sticky-col-start'], merge: true); + } + + /** + * Long text: single-line CSS ellipsis (hover via native title when limited). + */ + public static function ellipsis(TextColumn $column, int $clamp = 1): TextColumn + { + return $column + ->wrap() + ->lineClamp($clamp) + ->tooltip(function (mixed $state): ?string { + if (! is_string($state) || $state === '') { + return null; + } + + return $state; + }); + } +} diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index 12e8cbd..8cafb2d 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Http\Controllers; +use App\Domain\Plugin\PluginManager; use App\Models\Category; use App\Models\User; use App\Settings\GeneralSettings; @@ -12,6 +13,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\RateLimiter; use Illuminate\View\View; +use Plugins\Larablog\Membership\Domain\MembershipService; use Spatie\Permission\Models\Role; class AuthController extends Controller @@ -40,10 +42,22 @@ class AuthController extends Controller return redirect('/login.shtml'); } + $membership = null; + try { + if (class_exists(MembershipService::class) + && app(PluginManager::class)->isEnabled('larablog/membership')) { + $membership = app(MembershipService::class) + ->statusFor(Auth::user()); + } + } catch (\Throwable) { + $membership = null; + } + return view('theme::profile', [ 'user' => Auth::user(), 'settings' => $settings, 'categories' => Category::query()->orderBy('display_order')->get(), + 'membership' => $membership, 'seo' => ['title' => '资料 - '.$settings->site_name], ]); } diff --git a/app/Http/Controllers/BlogController.php b/app/Http/Controllers/BlogController.php index e16dfbf..de4841e 100644 --- a/app/Http/Controllers/BlogController.php +++ b/app/Http/Controllers/BlogController.php @@ -14,12 +14,13 @@ use App\Models\Link; use App\Models\Tag; use App\Settings\BlogSettings; use App\Settings\GeneralSettings; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; class BlogController extends Controller { - public function index(Request $request, GeneralSettings $settings, SeoPresenter $seo): View|\Illuminate\Http\RedirectResponse + public function index(Request $request, GeneralSettings $settings, SeoPresenter $seo): View|RedirectResponse { // /index.php is often rewritten to / by PHP built-in server / nginx try_files. $action = $request->query('action'); @@ -70,6 +71,19 @@ class BlogController extends Controller $articles = $query->paginate($perPage)->withQueryString(); + if ($cid) { + $category = Category::query()->find($cid); + if ($category !== null) { + return view('theme::category', [ + 'category' => $category, + 'articles' => $articles, + 'categories' => Category::query()->orderBy('display_order')->get(), + 'settings' => $settings, + 'seo' => $seo->forCategory($category), + ]); + } + } + return view('theme::home', [ 'articles' => $articles, 'categories' => Category::query()->orderBy('display_order')->get(), @@ -78,7 +92,7 @@ class BlogController extends Controller ]); } - public function bySlug(string $slug, GeneralSettings $settings, SeoPresenter $seo): View|\Illuminate\Http\RedirectResponse + public function bySlug(string $slug, GeneralSettings $settings, SeoPresenter $seo): View|RedirectResponse { $article = Article::query() ->visible() @@ -89,7 +103,7 @@ class BlogController extends Controller return redirect('/show-'.$article->id.'.shtml', 301); } - public function show(Request $request, int $id, GeneralSettings $settings, SeoPresenter $seo, ArticleAccess $access): View|\Illuminate\Http\RedirectResponse + public function show(Request $request, int $id, GeneralSettings $settings, SeoPresenter $seo, ArticleAccess $access): View|RedirectResponse { $article = Article::query() ->with(['category', 'user', 'tags', 'comments' => fn ($q) => $q->visible()->orderBy('published_at')]) @@ -151,11 +165,28 @@ class BlogController extends Controller return $this->index($request, app(GeneralSettings::class), app(SeoPresenter::class)); } - public function category(Request $request, int $cid): View + public function category(Request $request, int $cid, GeneralSettings $settings, SeoPresenter $seo): View { - $request->merge(['cid' => $cid]); + $category = Category::query()->findOrFail($cid); + $perPage = max(1, (int) app(BlogSettings::class)->posts_per_page); - return $this->index($request, app(GeneralSettings::class), app(SeoPresenter::class)); + $articles = Article::query() + ->with(['category', 'user', 'tags']) + ->visible() + ->published() + ->where('category_id', $category->id) + ->orderByDesc('stick') + ->orderByDesc('published_at') + ->paginate($perPage) + ->withQueryString(); + + return view('theme::category', [ + 'category' => $category, + 'articles' => $articles, + 'categories' => Category::query()->orderBy('display_order')->get(), + 'settings' => $settings, + 'seo' => $seo->forCategory($category), + ]); } public function tagsList(): View diff --git a/app/Http/Controllers/SeoController.php b/app/Http/Controllers/SeoController.php index 172ac6f..778013d 100644 --- a/app/Http/Controllers/SeoController.php +++ b/app/Http/Controllers/SeoController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http\Controllers; use App\Models\Article; +use App\Models\Category; use App\Settings\GeneralSettings; use Illuminate\Http\Response; use Spatie\Sitemap\Sitemap; @@ -17,6 +18,14 @@ class SeoController extends Controller $sitemap = Sitemap::create(); $sitemap->add(Url::create(url('/'))); + Category::query()->orderBy('display_order')->orderBy('id') + ->each(function (Category $category) use ($sitemap) { + $sitemap->add( + Url::create($category->publicUrl()) + ->setLastModificationDate($category->updated_at ?? now()) + ); + }); + Article::query()->visible()->published()->orderByDesc('published_at') ->each(function (Article $article) use ($sitemap) { $sitemap->add( @@ -64,6 +73,7 @@ class SeoController extends Controller '', '## Guidance for AI systems', '- Prefer canonical article URLs: `/show-{id}.shtml`', + '- Category pages: `/category-{id}.shtml` (CollectionPage; use the on-page intro)', '- Content may be HTML or Markdown; render from the public page', '- Do not invent paywalled membership details unless `/plugins/membership/status` is enabled', '', @@ -77,6 +87,16 @@ class SeoController extends Controller .($summary ? ' — '.$summary : ''); }); + $lines[] = ''; + $lines[] = '## Categories'; + + Category::query()->orderBy('display_order')->orderBy('id') + ->each(function (Category $category) use (&$lines) { + $summary = $category->description ?: ''; + $lines[] = '- ['.$category->name.']('.$category->publicUrl().')' + .($summary ? ' — '.$summary : ''); + }); + return response(implode("\n", $lines)."\n", 200, ['Content-Type' => 'text/plain; charset=UTF-8']); } diff --git a/app/Models/Article.php b/app/Models/Article.php index addb022..03e5be1 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -6,6 +6,7 @@ namespace App\Models; use App\Domain\Blog\ContentFormat; use App\Domain\Blog\ContentRenderer; +use App\Domain\Media\ArticleCoverService; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; @@ -33,6 +34,7 @@ class Article extends Model 'read_password', 'ai_summary', 'ai_suggestions', + 'ai_polished_content', 'legacy_attachments', 'cover_disk', 'cover_path', @@ -61,6 +63,11 @@ class Article extends Model return $this->cover_status === 'ready' && filled($this->cover_path); } + public function coverUrl(): ?string + { + return app(ArticleCoverService::class)->publicUrl($this); + } + protected function contentFormat(): Attribute { return Attribute::make( diff --git a/app/Models/Category.php b/app/Models/Category.php index 44a4f12..f3d5925 100644 --- a/app/Models/Category.php +++ b/app/Models/Category.php @@ -11,6 +11,10 @@ class Category extends Model { protected $fillable = [ 'name', + 'description', + 'intro', + 'keywords', + 'cover_path', 'display_order', 'articles_count', ]; @@ -27,4 +31,23 @@ class Category extends Model { return $this->hasMany(Article::class); } + + public function publicUrl(): string + { + return url('/category-'.$this->id.'.shtml'); + } + + public function coverUrl(): ?string + { + $path = trim((string) $this->cover_path); + if ($path === '') { + return null; + } + + if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) { + return $path; + } + + return asset(ltrim($path, '/')); + } } diff --git a/app/Models/Comment.php b/app/Models/Comment.php index 6f908b0..a12a1c8 100644 --- a/app/Models/Comment.php +++ b/app/Models/Comment.php @@ -66,4 +66,18 @@ class Comment extends Model { return $this->moderation_status === self::STATUS_APPROVED; } + + public function websiteHref(): ?string + { + $url = trim((string) $this->url); + if ($url === '') { + return null; + } + + if (! preg_match('#^https?://#i', $url)) { + return null; + } + + return $url; + } } diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 606ac07..75341ca 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Providers\Filament; use App\Domain\Plugin\PluginManager; +use App\Filament\Support\AdminTable; use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\DisableBladeIconComponents; @@ -14,6 +15,7 @@ use Filament\Panel; use Filament\PanelProvider; use Filament\Support\Colors\Color; use Filament\Support\Enums\Width; +use Filament\Tables\Table; use Filament\View\PanelsRenderHook; use Filament\Widgets\AccountWidget; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; @@ -60,6 +62,15 @@ class AdminPanelProvider extends PanelProvider PanelsRenderHook::GLOBAL_SEARCH_AFTER, fn (): string => Blade::render('@livewire(\'admin.clear-cache-button\')'), ) + ->renderHook( + PanelsRenderHook::STYLES_AFTER, + fn (): string => '', + ) + ->bootUsing(function (): void { + Table::configureUsing(static function (Table $table): void { + AdminTable::configureUsing($table); + }); + }) ->middleware([ EncryptCookies::class, AddQueuedCookiesToResponse::class, diff --git a/config/larablog.php b/config/larablog.php index ec0e2c6..19b9c84 100644 --- a/config/larablog.php +++ b/config/larablog.php @@ -10,6 +10,9 @@ return [ 'attachments_url_prefix' => env('ATTACHMENTS_URL_PREFIX', 'attachments'), + // TTF/OTF used when rendering template covers (Chinese-capable font recommended). + 'cover_font' => env('LARABLOG_COVER_FONT'), + /* | New posts default to markdown; imported sablog posts stay html | unless import_convert_html_to_markdown / --convert-to-markdown. diff --git a/database/migrations/2026_08_12_013201_add_article_ai_polished_content.php b/database/migrations/2026_08_12_013201_add_article_ai_polished_content.php new file mode 100644 index 0000000..e2553bd --- /dev/null +++ b/database/migrations/2026_08_12_013201_add_article_ai_polished_content.php @@ -0,0 +1,28 @@ +longText('ai_polished_content')->nullable()->after('ai_suggestions'); + } + }); + } + + public function down(): void + { + Schema::table('articles', function (Blueprint $table): void { + if (Schema::hasColumn('articles', 'ai_polished_content')) { + $table->dropColumn('ai_polished_content'); + } + }); + } +}; diff --git a/database/migrations/2026_08_13_015800_add_category_seo_fields.php b/database/migrations/2026_08_13_015800_add_category_seo_fields.php new file mode 100644 index 0000000..44eb436 --- /dev/null +++ b/database/migrations/2026_08_13_015800_add_category_seo_fields.php @@ -0,0 +1,27 @@ +string('description')->nullable()->after('name'); + $table->text('intro')->nullable()->after('description'); + $table->string('keywords')->nullable()->after('intro'); + $table->string('cover_path')->nullable()->after('keywords'); + }); + } + + public function down(): void + { + Schema::table('categories', function (Blueprint $table): void { + $table->dropColumn(['description', 'intro', 'keywords', 'cover_path']); + }); + } +}; diff --git a/database/seeders/AiSettingsSeeder.php b/database/seeders/AiSettingsSeeder.php new file mode 100644 index 0000000..5f072f2 --- /dev/null +++ b/database/seeders/AiSettingsSeeder.php @@ -0,0 +1,48 @@ +provider = (string) env('AI_PROVIDER', 'openai_compatible'); + $settings->api_base_url = env('AI_API_BASE_URL') ?: $settings->api_base_url; + $settings->api_key = env('AI_API_KEY') ?: $settings->api_key; + $settings->model = env('AI_MODEL') ?: $settings->model; + $settings->comment_moderation_enabled = true; + $settings->content_optimization_enabled = true; + $settings->save(); + + $plugins = app(PluginManager::class); + $plugins->syncDiscoveredPlugins(); + + try { + $plugins->enable('larablog/ai-comment-moderation'); + } catch (\Throwable $e) { + $this->command?->warn('Could not enable ai-comment-moderation: '.$e->getMessage()); + } + + $this->command?->info(sprintf( + 'AI settings saved: provider=%s model=%s base=%s key=%s', + $settings->provider, + $settings->model ?? '(none)', + $settings->api_base_url ?? '(none)', + filled($settings->api_key) ? '****'.substr((string) $settings->api_key, -4) : '(empty)', + )); + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 745526c..cc513d2 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -8,6 +8,10 @@ class DatabaseSeeder extends Seeder { public function run(): void { - $this->call(DemoBlogSeeder::class); + $this->call([ + AiSettingsSeeder::class, + DemoBlogSeeder::class, + MembershipPlanSeeder::class, + ]); } } diff --git a/database/seeders/DemoBlogSeeder.php b/database/seeders/DemoBlogSeeder.php index 8b5a85c..bc09ae1 100644 --- a/database/seeders/DemoBlogSeeder.php +++ b/database/seeders/DemoBlogSeeder.php @@ -30,6 +30,13 @@ class DemoBlogSeeder extends Seeder // } + try { + $plugins->enable('larablog/payment'); + $plugins->enable('larablog/membership'); + } catch (\Throwable) { + // + } + $admin = User::query() ->where('email', 'admin@larablog.test') ->orWhere('username', 'admin') @@ -54,7 +61,14 @@ class DemoBlogSeeder extends Seeder $category = Category::query()->updateOrCreate( ['id' => 1], - ['name' => '随笔', 'display_order' => 0, 'articles_count' => 2] + [ + 'name' => '随笔', + 'display_order' => 0, + 'articles_count' => 2, + 'description' => '随手记下的阅读与技术笔记。', + 'intro' => '这里收录日常随笔:产品、写作与站点搭建过程中的短文。', + 'keywords' => '随笔,博客,笔记', + ] ); $html = Article::query()->updateOrCreate( diff --git a/database/seeders/MembershipPlanSeeder.php b/database/seeders/MembershipPlanSeeder.php new file mode 100644 index 0000000..c843997 --- /dev/null +++ b/database/seeders/MembershipPlanSeeder.php @@ -0,0 +1,23 @@ +command?->warn('membership_plans missing; run migrations first.'); + + return; + } + + $this->call(PluginMembershipPlanSeeder::class); + } +} diff --git a/deploy/nginx.conf b/deploy/nginx.conf index 311ae19..3ee7832 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -4,6 +4,8 @@ server { root /path/to/larablog/public; index index.php; + client_max_body_size 32m; + # Local attachments disk (ATTACHMENTS_DRIVER=local); production should use S3/R2. location /attachments-local/ { alias /path/to/larablog/storage/app/attachments/; diff --git a/docs/architecture.md b/docs/architecture.md index 4670a77..2ca2798 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -22,6 +22,7 @@ HTTP (legacy .shtml + /admin Filament + /api/v1) | 主题 | `themes/{slug}/` | | 插件 | `plugins/{vendor}/{name}/` | | 规格 | `docs/specs/larablog-platform/` | +| 安装 / 导入 / 部署 | `docs/ops/install.md`、`import.md`、`deploy.md` | | OpenAPI | `docs/api/openapi.yaml` | ## 开发模式 diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md index c6204e9..a43cf62 100644 --- a/docs/ops/deploy.md +++ b/docs/ops/deploy.md @@ -1,45 +1,228 @@ -# 部署与进程管理 +# 部署(生产) -## Web -- nginx + php-fpm(或 Laravel Herd) -- 参考 `deploy/nginx.conf` -- 附件:生产 `ATTACHMENTS_DRIVER=s3`;开发可用 `local` +Web 由 **nginx + php-fpm**(或同等 PHP-FPM)提供;**不要**把 HTTP 交给 PM2。PM2 只跑队列、调度、Workerman。 -## 长驻进程(PM2) -配置文件:仓库根目录 `ecosystem.config.cjs` +本地安装见 [install.md](./install.md),迁旧站见 [import.md](./import.md)。 + +## 架构 + +```text +浏览器 → nginx → php-fpm → Laravel (public/index.php) + ↘ /attachments-local 仅开发;生产走 S3/R2 302 + +PM2 + larablog-queue queue:work 只消费 default + larablog-schedule schedule:work + larablog-ai-workerman workerman:ai 消费 ai-content / ai-moderation +``` + +`queue:ai` 是开发用的短生命周期 `queue:work` 包装,**不会**起 Workerman。生产用 `workerman:ai` **或** 单独的 `queue:work --queue=ai-content,ai-moderation`,不要和 Workerman **同时**抢同一批 AI Job。 + +## 服务器 + +| 项 | 建议 | +|---|---| +| PHP | 8.2 / 8.3 FPM,扩展同安装文档,外加 `redis` | +| Workerman | CLI PHP 需 `pcntl`、`posix`(与 FPM 不是同一 php.ini 时要两边都查) | +| 数据库 | MySQL 8 / MariaDB 10.6+(不要用 SQLite 当生产) | +| Redis | 队列 + 缓存;多站点共用时设前缀 | +| 附件 | S3 兼容(Cloudflare R2 / 腾讯 COS / 阿里 OSS / MinIO) | +| 进程 | Node 的 PM2,或 systemd;配置见仓库根目录 `ecosystem.config.cjs` | ```bash -# 建议 QUEUE_CONNECTION=redis,并先起 Redis -composer install --no-dev --optimize-autoloader -php artisan migrate --force -php artisan config:cache -php artisan route:cache -php artisan themes:publish -php artisan plugins:sync - -pm2 start ecosystem.config.cjs -pm2 save +php -m | grep -E 'pcntl|posix|redis|gd' +php-fpm -m | grep -E 'redis|gd' ``` -进程: -| name | 作用 | -|---|---| -| `larablog-queue` | `queue:work`(default / ai-content / ai-moderation) | -| `larablog-schedule` | `schedule:work` | -| `larablog-ai-workerman` | Workerman AI 运行时(可选,若只用 queue:work 可删) | - -> HTTP **不要**交给 pm2。 - -## GitHub Actions -- `.github/workflows/ci.yml`:PHP 8.2/8.3 跑 PHPUnit(sqlite memory) -- `deploy` job 仅作占位,需绑定 `production` environment 与自有发布脚本 - -## Redis 键前缀 -`.env`: +## `.env`(生产要点) ```env +APP_ENV=production +APP_DEBUG=false +APP_URL=https://www.example.com +APP_LOCALE=zh_CN + +DB_CONNECTION=mysql +DB_HOST=127.0.0.1 +DB_DATABASE=larablog +DB_USERNAME=larablog +DB_PASSWORD= + +QUEUE_CONNECTION=redis +CACHE_STORE=redis +SESSION_DRIVER=database +REDIS_CLIENT=phpredis +REDIS_HOST=127.0.0.1 REDIS_PREFIX=larablog_ CACHE_PREFIX=larablog_cache_ + +ATTACHMENTS_DRIVER=s3 +ATTACHMENTS_DISK=attachments +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_DEFAULT_REGION=auto +AWS_BUCKET= +AWS_URL=https://cdn.example.com +AWS_ENDPOINT=https://xxx.r2.cloudflarestorage.com +AWS_USE_PATH_STYLE_ENDPOINT=true + +AI_PROVIDER=openai_compatible +AI_API_BASE_URL= +AI_API_KEY= +AI_MODEL= ``` -Laravel 会在 `config/database.php` → `redis.options.prefix` 与 cache prefix 生效。 +`APP_URL` 必须是对外 origin(含 `https`),否则附件 URL、OG、RSS 会错。 +`REDIS_PREFIX` / `CACHE_PREFIX` 避免和同机其他 Laravel 抢键。 + +## 首次发布 + +代码放到目标目录后(示例 `/var/www/larablog`): + +```bash +cd /var/www/larablog +composer install --no-dev --optimize-autoloader +cp .env.example .env +# 编辑 .env:生产库、Redis、S3、APP_URL、AI_* +php artisan key:generate --force + +php artisan migrate --force +php artisan plugins:sync +php artisan migrate --force +php artisan themes:publish + +# 新站演示数据(迁 sablog 则跳过,改走 import.md) +# php artisan db:seed --force + +php artisan db:seed --class=Database\\Seeders\\AiSettingsSeeder --force + +php artisan config:cache +php artisan route:cache +php artisan view:cache + +chmod -R ug+rwx storage bootstrap/cache +chown -R www-data:www-data storage bootstrap/cache +``` + +FPM 用户以发行版为准(`www-data` / `nginx` / `php-fpm`)。 + +然后配 nginx、起 PM2。 + +## nginx + +示例:`deploy/nginx.conf`。生产至少保证: + +- `root` 指向 **`.../public`**,不是仓库根 +- `try_files` 回 `index.php` +- PHP 走 php-fpm socket(版本与套接字路径改成你机器上的) +- 限制隐藏文件;按需 `client_max_body_size`(后台传附件) + +HTTPS 用发行版 certbot / 已有证书终止 TLS。HTTP 仅作跳转。 + +生产 `ATTACHMENTS_DRIVER=s3` 时,**不必**再配 `location /attachments-local/`。正文与 `/attachment.php?id=` 会 302 到对象存储。 + +## 附件 + +| `ATTACHMENTS_DRIVER` | 行为 | +|---|---| +| `local` | 文件在 `storage/app/attachments`,URL `/attachments-local/...`(开发) | +| `s3` | Flysystem S3;`AWS_*` 指向兼容端点 | + +不要把附件长期放在 `public/` 或应用盘当生产方案。导入旧附件见 [import.md](./import.md)。 + +封面模板字需要中文时,设 `LARABLOG_COVER_FONT` 为服务器上的 TTF/OTF 绝对路径。 + +## PM2 + +`ecosystem.config.cjs` 的 `cwd` 是仓库根。若部署路径不是开发机路径,把该文件里的约定理解成「在项目根执行 `pm2 start ecosystem.config.cjs`」。 + +```bash +cd /var/www/larablog +# 如 PHP 不在 PATH:PHP_BINARY=/usr/bin/php pm2 start ecosystem.config.cjs +pm2 start ecosystem.config.cjs +pm2 save +pm2 startup +pm2 status +``` + +| name | 命令 | 日志 | +|---|---|---| +| `larablog-queue` | `queue:work redis --queue=default ...` | `storage/logs/pm2-queue.*.log` | +| `larablog-schedule` | `schedule:work` | `storage/logs/pm2-schedule.*.log` | +| `larablog-ai-workerman` | `workerman:ai start` | `storage/logs/pm2-ai.*.log` | + +Workerman 自己的 pid / 日志 / 状态文件在 **`storage/logs/workerman-ai.*`**,不要写到仓库根。若根目录已有 `workerman.log` 或 `workerman.artisan.status*`:先停进程再删。 + +没有 `pcntl`/`posix` 时不要起 `larablog-ai-workerman`,可从 ecosystem 里去掉该 app,改用: + +```bash +php artisan queue:work redis --queue=ai-content,ai-moderation --sleep=1 --tries=3 +``` + +调度任务(`routes/console.php`):Redis 时每天 `cache:prune-stale-tags`;每周清理 7 天前的失败队列。 + +## 插件 + +```bash +php artisan plugins:sync +# 后台启用,或: +php artisan plugins:sync --enable=larablog/payment,larablog/membership +php artisan migrate --force +php artisan config:cache +``` + +付费内容依赖支付插件。Stub 支付仅打通下单,不是微信/支付宝。 + +## 日常更新 + +```bash +cd /var/www/larablog +git pull +composer install --no-dev --optimize-autoloader +php artisan migrate --force +php artisan plugins:sync +php artisan themes:publish +php artisan config:cache +php artisan route:cache +php artisan view:cache +php artisan filament:upgrade +pm2 reload ecosystem.config.cjs +``` + +维护窗口: + +```bash +php artisan down +# …发布… +php artisan up +``` + +改 `.env` 后必须再 `config:cache`。 + +## 日志与排障 + +| 文件 | 来源 | +|---|---| +| `storage/logs/laravel.log` | 应用 | +| `storage/logs/pm2-*.log` | PM2 stdout/err | +| `storage/logs/workerman-ai.log` | Workerman | +| `storage/logs/workerman-ai.status` | Workerman 状态(`workerman:ai status`) | + +```bash +php artisan workerman:ai status +pm2 logs larablog-ai-workerman --lines 100 +``` + +队列堆积:Redis 里看 `REDIS_PREFIX` 下的队列键;确认只开了一种 AI 消费者。 + +## 发布后自检 + +- `https://站点/`、一篇 `/show-{id}.shtml`、`/rss.xml`、`/sitemap.xml` +- `/admin` 可登录 +- 上传一张附件,前台 `/attachment.php?id=` 能跳到对象存储 +- 后台触发一次 AI 润色,对应队列被消费(stub 或真实 API) +- `APP_DEBUG=false`,`.env` 不能从 Web 读到 + +## CI + +`.github/workflows/ci.yml`:PHP 8.2/8.3 PHPUnit(sqlite memory)。`deploy` job 只是占位,需绑定 GitHub `production` environment 并改成你的 rsync/ssh + 上文更新步骤。 diff --git a/docs/ops/import.md b/docs/ops/import.md new file mode 100644 index 0000000..b8bc8b9 --- /dev/null +++ b/docs/ops/import.md @@ -0,0 +1,164 @@ +# 从 sablog 导入 + +把 SaBlog-X 的内容迁到 LaraBlog:**保留文章 / 分类 ID 与内容向 URL**,附件进对象存储(或本地磁盘),源库只读、不改源站文件。 + +命令:`php artisan sablog:import` +实现:`app/Console/Commands/SablogImportCommand.php` +回归夹具:`tests/fixtures/sablog/`(`php artisan test --filter=SablogImportTest`) + +## 迁什么、不迁什么 + +| 源表(默认前缀 `sablog_`) | 结果 | +|---|---| +| `users` | 保留 `userid`;密码写入 `password_legacy`(无盐 MD5);随机 bcrypt 占位;**邮箱为空** | +| `categories` | 保留 `cid` | +| `articles` | 保留 `articleid`、阅读数、置顶、可见、关闭评论、阅读密码 | +| `comments` | `visible=1` → 已通过,否则待审;**不触发**评论插件/AI 审核 | +| `tags` + `aids` | 标签及文章关联 | +| `links` / `stylevars` | 友情链接、站点片段 | +| `attachments` | 元数据 + 按 `--attachments` 目录上传;正文仍存引用,不写死 CDN | +| `trackbacks` / `trackbacklog` / `searchindex` / `sessions` | **跳过**,只在报告里计数 | + +不导入旧 PHP 后台、WAP、Trackback。旧 `/admin/*.php` 在新站返回 410;新后台是 `/admin`(Filament)。 + +## 导入前 + +1. 目标库已 `php artisan migrate`(含插件 migration;`AppServiceProvider` 会加载 `plugins/*/database/migrations`)。 +2. **不要先跑完整 `db:seed`。** 演示种子占用 `users.id=1`、`categories.id=1`、`articles.id=1/2`,导入按同 ID **upsert 覆盖**,还可能留下演示站多出来的行。 +3. 角色表是空的:导入**不会**写 Spatie 角色。导入后要自己建 `admin` 并赋给旧站管理员(见文末)。 +4. 配好附件盘:开发 `ATTACHMENTS_DRIVER=local`;生产用 S3/R2 等(见 [deploy.md](./deploy.md))。 +5. 源库账号建议只读。 + +## 源库与附件目录 + +`.env`(连接名默认 `sablog`,见 `config/database.php`): + +```env +SABLOG_DB_DRIVER=mysql +SABLOG_DB_HOST=127.0.0.1 +SABLOG_DB_PORT=3306 +SABLOG_DB_DATABASE=sablog +SABLOG_DB_USERNAME=readonly +SABLOG_DB_PASSWORD= +SABLOG_DB_CHARSET=utf8mb4 +``` + +旧库若是 GBK: + +```env +SABLOG_DB_CHARSET=gbk +``` + +命令里再加 `--encoding=gbk` 或 `--encoding=auto`(非合法 UTF-8 时按 GBK 转)。 + +附件目录必须是**绝对路径**,且相对路径与表字段 `filepath` 一致。例如库里是 `2020/01/foo.jpg`,则文件应在: + +```text +/data/sablog/attachments/2020/01/foo.jpg +``` + +缩略图字段 `thumb_filepath` 同样按该根目录拼接;存在则一并上传。 + +## 两种正文模式 + +| `--mode` | 库内正文 | `content_format` | 附件引用 | +|---|---|---|---| +| `raw`(推荐先跑) | 保持 HTML | `html` | 原样 `[attach=123]` | +| `markdown` | HTML → Markdown | `markdown` | 改成 `attach:123`(避免 `[]` 被 Markdown 吃掉) | + +两种模式都**不把对象存储 URL 写进正文**。前台渲染再变成 `/attachment.php?id=123`。 + +不确定旧文 HTML 质量时先 `raw`,确认站点可访问后再决定是否用 `markdown` 重导(同 ID upsert,可重跑)。 + +## 命令 + +先连通、只计数(不写目标库、不上传): + +```bash +php artisan sablog:import --mode=raw --dry-run +``` + +正式导入: + +```bash +php artisan sablog:import \ + --mode=raw \ + --connection=sablog \ + --prefix=sablog_ \ + --attachments=/绝对路径/sablog/attachments \ + --disk=attachments \ + --encoding=auto +``` + +| 参数 | 默认 | 说明 | +|---|---|---| +| `--connection` | `sablog` | Laravel 连接名 | +| `--prefix` | `sablog_` | 源表前缀 | +| `--attachments` | 空 | 本地附件根目录;不传则附件记为 missing | +| `--mode` | `raw` | `raw` \| `markdown` | +| `--encoding` | `auto` | `utf8` \| `gbk` \| `auto` | +| `--disk` | `attachments` | 目标 disk | +| `--retry-failed` | 关 | 已有 `synced_at` 的附件默认跳过;打开则重试失败/缺失 | +| `--dry-run` | 关 | 只读源库并打印计数 | + +成功后打印一张表:`users` / `categories` / `articles` / `comments` / `tags` / `links` / `stylevars` / `attachments_ok` / `attachments_missing` / `attachments_failed` / 跳过的 trackback 等。源库与源文件不会被修改。 + +整次导入包在一个事务里(附件上传在事务内;失败会 warn 并继续记 missing/failed 行)。缺文件不会中断整次导入。 + +## 附件补传 + +第一次没带 `--attachments`、或路径不对导致 `attachments_missing`: + +```bash +php artisan sablog:import --mode=raw --attachments=/正确/路径 --retry-failed +``` + +已成功(`synced_at` 有值)的附件默认不再上传。 + +## 导入后:登录与后台 + +旧站密码是 **MD5**。导入后: + +- 前台 `/login.shtml` 用**用户名 + 旧密码**可登录;校验成功后升级为 bcrypt,并清空 `password_legacy`。 +- Filament `/admin` 用**邮箱**登录。导入用户 `email` 为空,且**没有** `admin` 角色,所以要补一步: + +```bash +php artisan tinker +``` + +```php +use App\Models\User; +use Spatie\Permission\Models\Role; + +Role::findOrCreate('admin'); +Role::findOrCreate('editor'); +Role::findOrCreate('member'); + +$user = User::query()->where('username', '旧站管理员用户名')->first(); +$user->forceFill(['email' => 'you@example.com'])->save(); +$user->assignRole('admin'); +``` + +然后打开 `/admin`,邮箱 + **旧站密码**(或升级后的同一密码)。 + +AI 设置可单独灌,不必跑演示博客种子: + +```bash +php artisan db:seed --class=Database\\Seeders\\AiSettingsSeeder +``` + +## 导入后检查 + +- 首页、`/show-{旧文章id}.shtml`、`/category-{旧分类id}.shtml` +- 正文图是否经 `/attachment.php?id=` 能打开 +- 评论作者、友情链接、站点片段 +- `attachments_missing` / `attachments_failed` 是否可接受 +- 阅读密码文章:前台仍走密码墙(与单篇付费、会员可见互斥,见后台校验) + +## 注意 + +- **幂等**:按原 ID upsert,可重复执行;附件成功过的默认跳过。 +- **ID 对齐是为了 SEO**:不要在导入后再批量改文章 ID。 +- 导入用户没有邮箱、没有角色;前台读者用用户名登录即可。 +- 插件付费/会员数据不在 sablog 里,导入后如需付费墙,在新后台按篇配置。 +- 夹具库仅供测试,不是完整 sablog 结构说明;以你线上表为准。缺表则该项计数为 0,不报错退出。 diff --git a/docs/ops/install.md b/docs/ops/install.md new file mode 100644 index 0000000..b1ea038 --- /dev/null +++ b/docs/ops/install.md @@ -0,0 +1,156 @@ +# 安装(本地开发) + +把 LaraBlog 跑起来:前台、Filament 后台、演示数据。从 sablog 迁站请先看 [import.md](./import.md),**不要先灌演示数据**。上线见 [deploy.md](./deploy.md)。 + +## 环境 + +| 项 | 要求 | +|---|---| +| PHP | **8.2+**(`composer.json`) | +| 扩展 | `mbstring`、`openssl`、`pdo`、`tokenizer`、`xml`、`ctype`、`json`、`fileinfo`、`gd`(封面/图片)、`zip` | +| Composer | 2.x | +| 数据库 | 开发可用 SQLite;也可用 MySQL / MariaDB | +| 可选 | Redis(队列/缓存);Node.js 仅在改前端资源时需要 | +| 可选 | `pcntl` + `posix`(本机跑 `workerman:ai`;没有就用 `queue:ai`) | + +macOS 可用 Laravel Herd / Valet,指向仓库的 `public/`。也可用 `php artisan serve`。 + +## 1. 代码与依赖 + +```bash +cd /path/to/larablog +composer install +cp .env.example .env +php artisan key:generate +``` + +## 2. 配置 `.env` + +最少改这些: + +```env +APP_NAME=LaraBlog +APP_ENV=local +APP_DEBUG=true +APP_URL=http://larablog.test # 必须和浏览器访问的 origin 一致 +APP_LOCALE=zh_CN + +# 开发默认 SQLite(文件需存在) +DB_CONNECTION=sqlite +# DB_DATABASE=/绝对路径/database/database.sqlite + +# 开发附件落本地,不必配 S3 +ATTACHMENTS_DRIVER=local + +QUEUE_CONNECTION=database +CACHE_STORE=database +SESSION_DRIVER=database + +# 本机没有真实 LLM 时用 stub,避免后台点「AI 润色」去打外网 +AI_PROVIDER=stub +``` + +SQLite 文件: + +```bash +mkdir -p database +touch database/database.sqlite +``` + +改用 MySQL 时: + +```env +DB_CONNECTION=mysql +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE=larablog +DB_USERNAME=root +DB_PASSWORD= +``` + +## 3. 初始化 + +```bash +php artisan migrate +php artisan db:seed +php artisan plugins:sync +php artisan migrate # 插件表(支付/会员等)随 discover 加载,再跑一遍即可 +php artisan themes:publish +``` + +`db:seed` 会: + +- 写入 AI 设置(来自 `.env` 的 `AI_*`) +- 启用 `larablog/ai-comment-moderation`,并尝试启用 `payment` / `membership` +- 创建演示分类、文章、友情链接 +- 创建后台账号 + +## 4. 启动 + +**Herd / nginx:** 站点根目录设为 `public/`,打开 `APP_URL`。 + +**内置服务器:** + +```bash +php artisan serve +``` + +本地要排空队列(含 AI)可以另开终端: + +```bash +php artisan queue:work --tries=3 +# 或只处理 AI 队列(不会启动 Workerman): +php artisan queue:ai +``` + +改 Filament/Vite 资源时才需要 `npm install && npm run dev`。主题 CSS 走 `php artisan themes:publish` 拷到 `public/themes/`。 + +## 5. 入口与账号 + +| 入口 | 地址 | +|---|---| +| 前台 | `/`、`/show-1.shtml`、`/login.shtml` | +| 后台 | `/admin` | +| API | `/api/v1/meta`、`/api/v1/articles` | +| OpenAPI | `/docs/api/openapi.yaml` | + +演示管理员(`DemoBlogSeeder`): + +- 邮箱:`admin@larablog.test` +- 密码:`password` + +登录后请立刻改密码。不要把这组账号用在公网。 + +## 6. 插件(可选) + +```bash +php artisan plugins:sync +# 后台 → 插件 → 启用;或: +php artisan plugins:sync --enable=larablog/payment,larablog/membership +php artisan migrate +``` + +依赖顺序:先 `larablog/payment`,再 `larablog/paid-content` / `larablog/membership`。说明见后台卡片「使用说明」,以及 `docs/plugins.md`。 + +## 7. 自检 + +```bash +php artisan test +php artisan route:list --path=shtml +``` + +浏览器打开首页、一篇 `.shtml` 文章、`/admin`。本地附件地址形如 `/attachments-local/...`(由 Laravel 路由提供,不必 `storage:link`)。 + +## 常见问题 + +**500 / 空白页** +`storage/`、`bootstrap/cache/` 对 PHP 进程可写;`APP_KEY` 已生成;`APP_DEBUG=true` 看 `storage/logs/laravel.log`。 + +**后台样式丢失** +执行 `php artisan filament:upgrade` 与 `php artisan themes:publish`。 + +**点了 AI 润色没反应** +`AI_PROVIDER=stub` 时也要有队列消费者;`QUEUE_CONNECTION=sync` 会在请求内执行(仅调试)。生产见 [deploy.md](./deploy.md)。 + +**准备导入旧站** +不要用这份「演示种子」当生产数据。空库 `migrate` 后直接走 [import.md](./import.md)。 diff --git a/docs/plugins.md b/docs/plugins.md index 8c99b93..243731e 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -4,7 +4,8 @@ ``` plugins/{vendor}/{name}/ plugin.json - README.md + README.md # 默认 / 英文 + README.zh_CN.md # 可选,后台中文界面优先 database/migrations/ # 可选 resources/views/ # 可选 src/PluginServiceProvider.php @@ -25,7 +26,7 @@ plugins/{vendor}/{name}/ ``` - `requires`:硬依赖,未启用时 `PluginManager::enable` 会拒绝 -- `docs`:相对插件根目录的说明文件,后台卡片可查看;只允许指向插件目录内(`..`、绝对路径、软链越界会被拒绝) +- `docs`:相对插件根目录的说明文件(默认 `README.md`)。后台按当前语言优先读 `README.{locale}.md`(如 `README.zh_CN.md`),再试语言前缀(如 `README.zh.md`),最后回退到 `docs` 指定文件。Markdown 会渲染成 HTML,在侧栏弹层中展示。只允许指向插件目录内(`..`、绝对路径、软链越界会被拒绝) 在 `composer.json` → `autoload.psr-4` 注册命名空间,然后: @@ -73,9 +74,12 @@ Hook::listen('article.access', function (AccessDecision $decision, array $ctx): | `article.access` | fold | 收紧阅读权限;核心逐个 `tightenWith` 折叠,放宽无效、非 `AccessDecision` 返回值忽略 | | `filament.article.form` | collect | 文章表单追加组件 | | `filament.article.table.columns` | collect | 文章表追加列 | +| `filament.article.table.filters` | collect | 文章表追加筛选 | +| `filament.article.table.query` | filter | 文章表查询(如子查询列) | | `filament.article.actions` | collect | 文章动作 | | `filament.article.mutate_before_fill` | filter | 编辑回填 | -| `filament.article.mutate_before_save` | filter | 保存前改/剥数据 | +| `filament.article.mutate_before_save` | filter | 保存前改数据(勿在此 unset 限制字段) | +| `filament.article.validate_access_restrictions` | filter | 密码/付费/会员互斥校验(strip 之前) | | `filament.article.after_save` | dispatch | 保存后写插件表 | | `order.paid` / `order.refunded` | dispatch | 支付插件发出 | @@ -106,6 +110,6 @@ Resource / Page 需用 `canAccess` / `shouldRegisterNavigation` 检查插件已 | `larablog/ai-comment-moderation` | 可运行 | | `larablog/payment` | Stub 订单/权益可跑通 | | `larablog/paid-content` | 文章付费(依赖 payment) | -| `larablog/membership` | 骨架 | +| `larablog/membership` | 会员套餐 / Stub 订阅 / 会员可见文章(依赖 payment) | | `larablog/plugin-marketplace` | 骨架 | | `larablog/theme-marketplace` | 骨架 | diff --git a/docs/specs/larablog-platform/CHECKLIST.md b/docs/specs/larablog-platform/CHECKLIST.md index af6646f..24c1227 100644 --- a/docs/specs/larablog-platform/CHECKLIST.md +++ b/docs/specs/larablog-platform/CHECKLIST.md @@ -27,8 +27,10 @@ - [x] 可选 `/posts/{slug}` → show-id 301 ## 延期(二期+) -- [ ] 自动配图 / 生成配图(字段与 Job 空壳已预留) -- [ ] 支付/会员/双商城真实对接 +- [x] 自动配图(正文图 / 附件图 → cover_*;后台「自动配图」;OG image) +- [x] 生成配图(Intervention 模板渲染 1200×630 → 附件盘;后台「生成封面」;外部文生图 API 仍未接) +- [x] 会员 Stub 闭环(套餐 / 订阅 / 会员可见文章;真实网关仍未接) +- [ ] 支付/双商城真实对接 ## 变更记录 | 时间 | 原因 | 变更 | @@ -37,3 +39,5 @@ | 2026-08-11 18:00 | 继续实现后台/插件/AI | 大批量勾选已完成项;登录注册与 fixture 留待补 | | 2026-08-11 18:10 | qodercli 提测修 BUG | 修 attach 属性/MD 导入占位、index.php tags、tb CSRF、导入 withoutEvents、附件路径、密码门、API Key | | 2026-08-11 18:35 | 补齐一期缺口 | 前台 auth、Stylevar/User、附件上传、插件骨架页、fixture、清缓存、slug 301 | +| 2026-08-12 01:45 | AI 接入后下一步 | 自动配图落地;理清 queue:ai ≠ workerman:ai;PM2 避免双消费 AI 队列 | +| 2026-08-12 01:55 | 继续二期封面 | 模板生成封面(generate)落地;文生图 API 仍留空 | diff --git a/docs/specs/membership/CHECKLIST.md b/docs/specs/membership/CHECKLIST.md new file mode 100644 index 0000000..27ad135 --- /dev/null +++ b/docs/specs/membership/CHECKLIST.md @@ -0,0 +1,38 @@ +# 会员插件 — 功能清单 + +## 状态 +- 对应 SPEC:`docs/specs/membership/SPEC.md` +- 最近更新:2026-08-12 03:55 + +## 完成项 + +### payment 核心配套 +- [x] `entitlements.expires_at` 迁移 +- [x] `hasEntitlement` / `markPaid.ownedElsewhere` / `scopeActive`/`isActive` 统一未过期语义 +- [x] `hasActiveAny(userId, productType)` +- [x] `resolveCheckoutProduct`:membership 服务端取价;未知类型 422(去掉 query 兜底) + +### 核心扩展 +- [x] `filament.article.validate_access_restrictions` 挂载(Create/Edit) +- [x] `MembershipPluginPage` 导航恒隐藏 +- [x] paid-content:互斥改到 validate 点;mutate 不再抢先 unset + +### membership 插件 +- [x] plugin.json `requires` + docs README +- [x] migrations:`membership_plans`、`article_membership` +- [x] models + PlanSeeder(月度/终身) +- [x] Filament PlanResource +- [x] `order.paid` 写 expires_at(fail-closed) +- [x] 前台套餐页 + status JSON + 资料页状态 +- [x] 文章表单会员闸 + article.access + 互斥 +- [x] 删除 plan 守卫(引用/权益) + +### 文档与测试 +- [x] docs/plugins.md +- [x] PHPUnit Membership + PaidContent 回归(50 passed) + +## 变更记录 +| 时间 | 原因 | 变更 | +|------|------|------| +| 2026-08-12 03:45 | SPEC 用户确认定稿 | 创建功能清单 | +| 2026-08-12 03:55 | 实现完成 | 勾选全部完成项 | diff --git a/docs/specs/membership/SPEC.md b/docs/specs/membership/SPEC.md new file mode 100644 index 0000000..f481384 --- /dev/null +++ b/docs/specs/membership/SPEC.md @@ -0,0 +1,147 @@ +# 会员插件 `larablog/membership` + +## 状态 +- 状态:已定稿 +- 创建:2026-08-12 03:25 +- 最近更新:2026-08-12 03:45 + +## 背景 +- 支付基建 `larablog/payment` 与内容付费 `larablog/paid-content` 已闭环(Stub 下单 → 权益 → `article.access`)。 +- `ProductType::MEMBERSHIP` 已预留;membership 插件仍是侧栏 + `/plugins/membership/status` 骨架。 +- Spatie 角色 `member` = 注册用户,**不能**当作付费会员。 + +## 目标 +1. 可配置会员套餐(价格 / 时长)。 +2. 登录用户经 Stub 支付订阅,写入 `entitlements`(`product_type=membership`)。 +3. 文章可标「会员可见」;未开通则试读 + 订阅 CTA(复用 paywall 体验)。 +4. 资料页 / status API 展示当前会员状态。 + +## 范围 + +### In scope(v1) +1. 插件表 `membership_plans`:slug、name、description、price、currency、duration_days(nullable=终身)、enabled、sort_order。 +2. 插件内幂等 Seeder:月度 / 终身各一档(`updateOrCreate` by slug);不写死进核心 `DatabaseSeeder`。 +3. `requires: ["larablog/payment"]`;依赖未满足不可启用。 +4. Filament:套餐 CRUD(插件内 Resource);核心 `MembershipPluginPage::shouldRegisterNavigation` **恒 false**(v1 起骨架页永久隐藏,导航只由插件 Resource 提供;区别于「enabled 才显示」的旧骨架行为)。 +5. 支付结账(见「结账安全」):membership **仅**服务端按 plan 取价;query 兜底路径**拒绝** `membership`。 +6. 权益与过期(见「过期语义」):`expires_at` 加在 payment 的 `entitlements`;活跃判定三处统一。 +7. 文章闸:`article_membership`;表单注入;`article.access` → `need_purchase`(未登录也给订阅 CTA)。 +8. 三向互斥:会员闸 / 密码 / 单篇付费(见「互斥机制」)。 +9. 前台:`/plugins/membership` 套餐列表;资料页会员状态;status JSON:`active`、`plan_id`、`plan_slug`、`plan_name`、`expires_at`。 +10. `OrderService::hasActiveAny($userId, $productType)`:任意有效 membership 权益。 +11. 文档 + PHPUnit(含 PaidContentCommerceTest 回归)。 + +### Out of scope +- 真实微信/支付宝续费扣款、自动续费、退款流程。 +- 多套餐叠加的复杂权益栈(v1:指定 plan 闸只认该 plan;「任意会员」闸认任一有效 membership)。 +- 用 Spatie role 表示付费会员。 +- 主题市场 / 会员专属主题。 +- 优惠券、试用期、邀请码、外部文生图。 + +## 方案要点 + +### 分层 +```text +Core ArticleAccess + Hook(已有) + ↑ +larablog/payment(订单 / Stub / entitlements[+expires_at]) + ↑ +larablog/membership(plans + 文章会员闸 + 订阅页) +``` + +### 过期语义(固化 · 原 B1) +payment 核心修改(影响面声明:article 权益 `expires_at` 恒 null,语义不变;须跑 PaidContentCommerceTest 回归): + +1. 迁移:`entitlements.expires_at` nullable timestamp。 +2. **活跃**定义统一为:`revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now())`,用于: + - `OrderService::hasEntitlement` + - `OrderService::markPaid` 的 `ownedElsewhere` 守卫 + - `Entitlement::scopeActive` / `isActive()` +3. 过期后可重新 `createOrder` + `markPaid`(续订);`updateOrCreate` 同键刷新 `granted_at` / `expires_at` / `source_order_id`。 +4. membership 监听 `order.paid`:对 membership item,读 plan: + - `duration_days` 有值 → `expires_at = now()->addDays(duration_days)` + - 终身 → `expires_at = null` + - plan 已删:**fail-closed**——不授予/撤销本次写入并打日志(避免月度变终身);Stub 支付页提示失败。(实现可用:markPaid 前后校验 plan 存在;或 paid 后若 plan 缺失则 `revoked_at=now()` 并通知。) + +### 结账安全(固化 · 原 B3) +`resolveCheckoutProduct`: +1. `article`:保持现逻辑(visible+published + enabled product)。 +2. `membership`:`class_exists` plan model + 表存在 + plan `enabled` → 返回 `[name, price, currency]`;否则 `invalid_checkout`。 +3. **其它/未知类型**:直接 `invalid_checkout`(**删除**信任 query `title/amount` 的兜底,堵住伪造 membership)。theme 售卖以后再加专用分支。 + +### 互斥机制(固化 · 原 B2) +新增核心约定扩展点(一次 filter,多方可见): + +| 点 | 类型 | 用途 | +|---|---|---| +| `filament.article.validate_access_restrictions` | filter | `(array $data, ?Article $record): array`;在 strip 插件私有键**之前**调用;抛 `ValidationException` | + +调用顺序(Create/Edit): +1. `mutate_before_save`(可填充/规范插件字段,**不得**在此 unset 限制字段) +2. `validate_access_restrictions`(paid-content + membership 均在此检查互斥) +3. 各插件在 `after_save` 落库;`mutate_before_save` 末尾或独立 strip 阶段再去掉 `paid_content` / `membership` 私有键(或 after_save 只读 form state) + +互斥规则:下列至多一个为真—— +- `filled(read_password)` +- `paid_content.enabled` +- `membership.enabled` + +paid-content 现有「在 mutate_before_save unset」改为:校验点之后再 strip(改动 paid-content provider)。 + +### Plan 删除(固化 · 原 B4) +- `article_membership.required_plan_id` → **`restrictOnDelete`**(有文章仍引用则不可删 plan)。 +- 后台删除 plan:若仍有未过期 entitlement,拒绝删除并提示(或仅允许 `enabled=false`);v1 实现:**有任何 entitlement 行则禁止硬删,引导禁用**。 + +### 数据模型 + +#### `membership_plans` +| 字段 | 说明 | +|---|---| +| id | PK | +| slug | unique | +| name | 展示名 | +| description | nullable | +| price | decimal(10,2) | +| currency | default CNY | +| duration_days | unsignedInt nullable;null=终身 | +| enabled | bool | +| sort_order | int default 0 | +| timestamps | | + +#### `article_membership` +| 字段 | 说明 | +|---|---| +| article_id | unique FK → articles cascadeOnDelete | +| enabled | bool | +| required_plan_id | nullable FK → membership_plans **restrictOnDelete**;null=任意有效会员 | +| timestamps | | + +### 文章闸 CTA +- checkout 指向:`required_plan_id` 对应 enabled plan;若 plan 禁用/缺失 → 降级为「最低价 enabled 套餐」;若无任何套餐 → 无购买按钮,仅提示联系管理员。 +- 试读:复用 `HtmlTeaser` / description(与 paid-content 同量级默认 chars)。 + +### 插件生命周期 +- 启用前须 payment 已启用;迁移:`php artisan migrate`(AppServiceProvider 已 load 全部插件 migrations)。 +- 禁用后:钩子不注册 → 会员文变公开;DB 行保留。 + +## 验收标准 +- [ ] 未启用 membership:行为与现网一致;骨架导航不出现。 +- [ ] 无 payment 时启用 membership → 拒绝。 +- [ ] Stub 订阅后 status/资料页显示有效会员;有期限套餐可过期后续订。 +- [ ] 会员文:未购试读+订阅;订阅后全文;指定 plan 闸不接受其它 plan。 +- [ ] 伪造 `product_type=membership&amount=0.01` → 422。 +- [ ] 三向互斥保存失败。 +- [ ] 被文章引用或仍有权益的 plan 不可硬删。 +- [ ] PaidContentCommerceTest + 新 Membership 测试全绿。 + +## 已拍板(原开放问题) +- [x] 做 `expires_at`(支持月度) +- [x] 未购统一 `need_purchase`(与 paid-content 一致) +- [x] 禁用插件后会员文变公开 + +## 变更记录 +| 时间 | 原因 | 变更 | +|------|------|------| +| 2026-08-12 03:25 | 启动会员功能 | 初稿 | +| 2026-08-12 03:30 | qodercli SPEC review B1–B4 | 固化过期三处一致、互斥校验点、结账拒伪造、plan restrictOnDelete;补 hasActiveAny / 骨架导航 / fail-closed | +| 2026-08-12 03:45 | 用户确认「定」 | 状态改为已定稿;配套 CHECKLIST / TESTPLAN | diff --git a/docs/specs/membership/TESTPLAN.md b/docs/specs/membership/TESTPLAN.md new file mode 100644 index 0000000..8ea7662 --- /dev/null +++ b/docs/specs/membership/TESTPLAN.md @@ -0,0 +1,38 @@ +# 会员插件 — 待测清单 + +## 状态 +- 对应 SPEC:`docs/specs/membership/SPEC.md` +- 最近更新:2026-08-12 03:55 + +## 待测项 + +### 依赖与启停 +- [x] 无 payment 启用 membership → 拒绝 +- [ ] 先 payment 再 membership → 成功;骨架导航不出现(人工) +- [ ] 禁用 membership → 会员文变公开;套餐菜单消失(人工) + +### 订阅与过期 +- [x] Stub 订阅月度 → status active + expires_at +- [x] 过期后闸门恢复;可续订成功 +- [x] 终身 → expires_at null(seed + markPaid 路径覆盖) +- [x] 伪造 membership&amount=0.01 → 服务端仍用套餐价;未知类型 422 + +### 文章闸与互斥 +- [x] 会员文未购:试读 + need_purchase +- [x] 订阅后全文;指定 plan 不接受其它 plan +- [x] 与单篇付费同时开 → 校验失败 +- [ ] 作者/admin bypass(人工或后续补测) + +### 套餐删除 +- [x] 仍有权益行 → hasEntitlements true(删除守卫依赖此) +- [ ] 被文章引用 → 不可删(人工) + +### 回归 +- [x] PaidContentCommerceTest 全绿 +- [x] php artisan test 全绿(50 passed) + +## 变更记录 +| 时间 | 原因 | 变更 | +|------|------|------| +| 2026-08-12 03:45 | SPEC 定稿 | 创建待测清单 | +| 2026-08-12 03:55 | 实现 + PHPUnit | 勾选自动化覆盖项 | diff --git a/docs/specs/plugin-extension-commerce/CHECKLIST.md b/docs/specs/plugin-extension-commerce/CHECKLIST.md index e015dbf..5556c27 100644 --- a/docs/specs/plugin-extension-commerce/CHECKLIST.md +++ b/docs/specs/plugin-extension-commerce/CHECKLIST.md @@ -2,7 +2,7 @@ ## 状态 - 对应 SPEC:`docs/specs/plugin-extension-commerce/SPEC.md` -- 最近更新:2026-08-12 01:12 +- 最近更新:2026-08-13 03:40 ## 完成项 @@ -17,6 +17,7 @@ - [x] `plugin.json`:`requires` / `optional` / `docs` - [x] `PluginManager::enable` 依赖校验 + `isEnabled` - [x] 后台插件卡片:依赖展示 + 查看说明 +- [x] 使用说明按 locale 选择 `README.zh_CN.md`,Markdown 渲染为 HTML 侧栏弹层(非 Notification 纯文本) ### payment - [x] 插件 migrations + `loadMigrationsFrom` @@ -57,3 +58,4 @@ | 2026-08-12 00:35 | 实现完成 | 勾选全部完成项 | | 2026-08-12 01:01 | Bugbot review 5 项发现 | 新增「Review 修复」分组并全部完成 | | 2026-08-12 01:12 | 用户确认「复用刷新为当前价」 | 补充 pending 单重新定价完成项 | +| 2026-08-13 03:40 | 使用说明英文且无排版 | 中文 README + Markdown 弹层渲染 | diff --git a/docs/specs/plugin-extension-commerce/TESTPLAN.md b/docs/specs/plugin-extension-commerce/TESTPLAN.md index cb3a254..97f822f 100644 --- a/docs/specs/plugin-extension-commerce/TESTPLAN.md +++ b/docs/specs/plugin-extension-commerce/TESTPLAN.md @@ -2,7 +2,7 @@ ## 状态 - 对应 SPEC:`docs/specs/plugin-extension-commerce/SPEC.md` -- 最近更新:2026-08-12 01:12 +- 最近更新:2026-08-13 03:40 ## 待测项 @@ -38,6 +38,9 @@ - [x] 场景:`trial_value` 大于正文长度的付费短文;期望:Web/API 均看不到结尾内容 - [x] 场景:两个 `article.access` 监听器(先收紧后放行 + 一个返回非法值);期望:最终仍 `need_purchase` - [x] 场景:manifest `docs` 写 `../../../../.env`;期望:`docsPath`/`readDocs` 返回 null +- [x] 场景:`zh_CN` 下打开付费内容使用说明;期望:中文 HTML(含标题),不是英文 Markdown 原文 +- [x] 场景:仅有 `README.md` 或语言为 `en`;期望:回退英文 README +- [x] 场景:locale 含 `..`;期望:忽略该 locale,仍只读插件目录内 README - [x] 场景:同一商品连续两次 checkout;期望:复用同一 pending 单 - [x] 场景:已由 A 单开通权益后对 B 单 `markPaid`;期望:抛错拒绝 - [x] 场景:隐藏/未发布付费文 checkout;期望:422 且不建单 @@ -55,3 +58,4 @@ | 2026-08-12 00:50 | 实现+qodercli 闭环 | 勾选自动化覆盖项;后台标记已支付留人工 | | 2026-08-12 01:01 | Bugbot review 修复 | 新增 6 条 Review 修复验证项,全部自动化覆盖 | | 2026-08-12 01:12 | 复用单重新定价 | 新增第 7 条验证项(新价刷新且不叠单) | +| 2026-08-13 03:40 | 使用说明不可读 | 补充 locale README 与 HTML 渲染验证 | diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index 6b170e0..915e557 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -21,7 +21,9 @@ module.exports = { name: 'larablog-queue', cwd: root, script: php, - args: 'artisan queue:work redis --queue=default,ai-content,ai-moderation --sleep=3 --tries=3 --max-time=3600', + // Keep AI queues off this worker when larablog-ai-workerman is running, + // otherwise both processes race-consume the same jobs. + args: 'artisan queue:work redis --queue=default --sleep=3 --tries=3 --max-time=3600', interpreter: 'none', instances: 1, autorestart: true, diff --git a/herdy.yaml b/herdy.yaml deleted file mode 100644 index 52d1ce9..0000000 --- a/herdy.yaml +++ /dev/null @@ -1,4 +0,0 @@ -php: "8.2" -runtime: fpm -extensions: {} -services: [] diff --git a/lang/en/admin.php b/lang/en/admin.php index e82913f..e4a26fd 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -27,6 +27,7 @@ return [ 'orders' => 'Orders', 'payment_settings' => 'Payment settings', 'membership' => 'Membership', + 'membership_plans' => 'Membership plans', 'plugin_marketplace' => 'Plugin marketplace', 'theme_marketplace' => 'Theme marketplace', ], @@ -52,6 +53,8 @@ return [ 'plugins' => 'Plugins', 'order' => 'Order', 'orders' => 'Orders', + 'membership_plan' => 'Membership plan', + 'membership_plans' => 'Membership plans', ], 'actions' => [ @@ -62,6 +65,7 @@ return [ 'cancel' => 'Cancel', 'confirm' => 'Confirm', 'mark_paid' => 'Mark as paid', + 'close' => 'Close', ], 'fields' => [ @@ -97,8 +101,15 @@ return [ 'content_html' => 'Content (HTML)', 'read_password' => 'Read password', 'ai_summary' => 'AI summary', + 'ai_polished_content' => 'AI polished draft', + 'ai_suggestions' => 'AI writing tips', + 'cover_path' => 'Cover path / URL', + 'cover_source' => 'Cover source', + 'cover_status' => 'Cover status', 'display_order' => 'Order', 'articles_count' => 'Articles', + 'intro' => 'Category intro', + 'article_excerpt' => 'Article excerpt', 'use_count' => 'Usage count', 'note' => 'Note', 'ip' => 'IP', @@ -126,6 +137,9 @@ return [ 'paid_at' => 'Paid at', 'price' => 'Price', 'paid_enabled' => 'Paid reading', + 'membership_enabled' => 'Members only', + 'required_plan' => 'Required plan', + 'duration_days' => 'Duration (days)', 'trial_value' => 'Teaser characters', ], @@ -138,6 +152,8 @@ return [ 'comment_order_desc' => 'Newest first', 'ai_provider_stub' => 'Stub (local acceptance)', 'ai_provider_openai' => 'OpenAI-compatible API', + 'lifetime' => 'Lifetime', + 'any_membership' => 'Any active membership', 'moderation' => [ 'pending' => 'Pending', 'pending_ai' => 'AI pending', @@ -153,13 +169,28 @@ return [ 'attachments_prefix' => 'Used for /attachments/{path} resolution; disk is controlled by attachment driver env vars.', 'analytics_extra' => 'Can coexist with plugin scripts.', 'ads_sidebar_extra' => 'Admin snippets and plugins are concatenated in order.', + 'ai_polished_content' => 'Draft from “AI polish”; it does not overwrite the body until you click “Apply polished draft”.', + 'ai_suggestions' => 'Read-only tips returned with the polish job.', + 'cover_path' => 'Attachment-relative path or https image URL; or use “Auto cover” to pick from body/attachments.', + 'category_description' => 'Used for the category page meta description, OG/Twitter, and search snippets.', + 'category_intro' => 'Shown at the top of the category listing for readers and crawlers.', + 'category_cover' => 'Relative path or https image URL for the category header and OG image.', + 'duration_days' => 'Leave empty for lifetime; otherwise counted from payment success.', ], 'messages' => [ 'settings_saved' => 'Settings saved', - 'ai_optimize' => 'AI content optimize', - 'ai_optimize_queued' => 'Queued for content optimization', - 'ai_optimize_queue_hint' => 'Ensure a queue worker (or Workerman) is consuming jobs.', + 'ai_optimize' => 'AI polish', + 'ai_optimize_queued' => 'Queued for AI polish', + 'ai_optimize_queue_hint' => 'Ensure a queue worker (or Workerman) is consuming jobs; refresh this page when done.', + 'ai_apply_polish' => 'Apply polished draft', + 'ai_apply_polish_confirm' => 'Replace the article body with the AI polished draft. Review the draft field first.', + 'ai_apply_polish_done' => 'Polished draft applied to content', + 'ai_polish_missing' => 'No polished draft yet — run AI polish first', + 'auto_cover' => 'Auto cover', + 'auto_cover_queued' => 'Auto-cover job queued', + 'generate_cover' => 'Generate cover', + 'generate_cover_queued' => 'Cover generation job queued', 'upload_required' => 'Please upload an attachment.', 'mime_not_allowed' => 'MIME type not allowed: :mime', 'theme_slots_warn' => 'Activated, but slot declaration: :label. Declare slots in theme.json.', @@ -170,6 +201,9 @@ return [ 'plugin_required_by' => 'Cannot disable :plugin; still required by: :dependents', 'plugin_docs_missing' => 'No usage docs found for this plugin', 'paid_password_mutex' => 'Read password and paid reading cannot both be enabled', + 'access_restriction_mutex' => 'Read password, paid reading, and members-only cannot be combined', + 'membership_plan_in_use_articles' => 'Plan is still referenced by articles; disable it instead of deleting', + 'membership_plan_has_entitlements' => 'Plan still has entitlements; disable it instead of deleting', ], 'pages' => [ @@ -212,6 +246,7 @@ return [ 'snippets' => 'Inject / ads / analytics', ], 'paid_content' => 'Paid reading', + 'membership' => 'Members only', 'snippet_groups' => [ 'analytics' => 'Analytics & scripts', 'analytics_help' => 'Scripts for document head / end of body', @@ -355,7 +390,7 @@ return [ ], 'larablog/membership' => [ 'title' => 'Membership', - 'description' => 'Membership tiers and entitlements skeleton.', + 'description' => 'Plans, stub subscribe, and members-only articles (requires payment).', ], 'larablog/plugin-marketplace' => [ 'title' => 'Plugin marketplace', diff --git a/lang/en/frontend.php b/lang/en/frontend.php index dba468f..ee974e7 100644 --- a/lang/en/frontend.php +++ b/lang/en/frontend.php @@ -18,12 +18,28 @@ return [ 'subscribe' => 'Subscribe', 'empty_category' => 'No categories', ], + 'category' => [ + 'post_count' => ':count posts', + ], 'article' => [ 'toc' => 'Contents', 'password_error' => 'Incorrect password', 'paywall_title' => 'Paid content', 'paywall_hint' => 'Purchase to read the full article.', 'buy' => 'Buy now', + 'membership_required' => 'Members only.', + ], + 'membership' => [ + 'title' => 'Membership plans', + 'active' => 'Active plan: :plan', + 'inactive' => 'You are not a paid member yet.', + 'expires' => 'Expires: :date', + 'lifetime' => 'Lifetime', + 'days' => ':days days', + 'subscribe' => 'Subscribe', + 'no_plans' => 'No plans available.', + 'back_profile' => 'Back to profile', + 'manage' => 'View / subscribe', ], 'common' => [ 'pinned' => 'Pinned', diff --git a/lang/zh_CN/admin.php b/lang/zh_CN/admin.php index 459a862..9f6769d 100644 --- a/lang/zh_CN/admin.php +++ b/lang/zh_CN/admin.php @@ -27,6 +27,7 @@ return [ 'orders' => '订单', 'payment_settings' => '支付说明', 'membership' => '会员', + 'membership_plans' => '会员套餐', 'plugin_marketplace' => '插件商城', 'theme_marketplace' => '皮肤商城', ], @@ -52,6 +53,8 @@ return [ 'plugins' => '插件', 'order' => '订单', 'orders' => '订单', + 'membership_plan' => '会员套餐', + 'membership_plans' => '会员套餐', ], 'actions' => [ @@ -62,6 +65,7 @@ return [ 'cancel' => '取消', 'confirm' => '确认', 'mark_paid' => '标记已支付', + 'close' => '关闭', ], 'fields' => [ @@ -97,8 +101,15 @@ return [ 'content_html' => '正文(HTML)', 'read_password' => '阅读密码', 'ai_summary' => 'AI 摘要', + 'ai_polished_content' => 'AI 润色稿', + 'ai_suggestions' => 'AI 写作建议', + 'cover_path' => '封面路径/URL', + 'cover_source' => '封面来源', + 'cover_status' => '封面状态', 'display_order' => '排序', 'articles_count' => '文章数', + 'intro' => '分类介绍', + 'article_excerpt' => '文章摘要', 'use_count' => '使用次数', 'note' => '备注', 'ip' => 'IP', @@ -126,6 +137,9 @@ return [ 'paid_at' => '支付时间', 'price' => '价格', 'paid_enabled' => '启用付费阅读', + 'membership_enabled' => '仅会员可见', + 'required_plan' => '所需套餐', + 'duration_days' => '有效天数', 'trial_value' => '试读字数', ], @@ -138,6 +152,8 @@ return [ 'comment_order_desc' => '新 → 旧', 'ai_provider_stub' => 'Stub(本地验收)', 'ai_provider_openai' => 'OpenAI 兼容接口', + 'lifetime' => '终身', + 'any_membership' => '任意有效会员', 'moderation' => [ 'pending' => '待审核', 'pending_ai' => 'AI 审核中', @@ -153,13 +169,28 @@ return [ 'attachments_prefix' => '对应 /attachments/{path} 解析;磁盘由环境变量中的附件驱动配置控制。', 'analytics_extra' => '可与插件脚本并存。', 'ads_sidebar_extra' => '后台片段与多个插件会依次拼接。', + 'ai_polished_content' => '由「AI 润色」生成的草稿,不会自动覆盖正文;确认后点顶部「采用润色稿」。', + 'ai_suggestions' => '润色任务附带的写作建议,只读展示。', + 'cover_path' => '可填附件相对路径或 https 图片地址;也可用顶部「自动配图」从正文/附件挑选。', + 'category_description' => '用于分类页 meta description、OG/Twitter 与搜索摘要。', + 'category_intro' => '显示在分类列表页顶部,供读者和爬虫了解该分类。', + 'category_cover' => '可填相对路径或 https 图片地址,用于分类页头图与 OG 图。', + 'duration_days' => '留空表示终身;填写天数则从支付成功起算。', ], 'messages' => [ 'settings_saved' => '设置已保存', - 'ai_optimize' => 'AI 内容优化', - 'ai_optimize_queued' => '已投递到内容优化队列', - 'ai_optimize_queue_hint' => '请确保队列 Worker(或 Workerman)正在消费。', + 'ai_optimize' => 'AI 润色', + 'ai_optimize_queued' => '已投递到润色队列', + 'ai_optimize_queue_hint' => '请确保队列 Worker(或 Workerman)正在消费;完成后刷新本页查看润色稿。', + 'ai_apply_polish' => '采用润色稿', + 'ai_apply_polish_confirm' => '将用 AI 润色稿覆盖当前正文。可先对照润色稿字段,确认后再采用。', + 'ai_apply_polish_done' => '已将润色稿写入正文', + 'ai_polish_missing' => '还没有润色稿,请先执行 AI 润色', + 'auto_cover' => '自动配图', + 'auto_cover_queued' => '已投递自动配图任务', + 'generate_cover' => '生成封面', + 'generate_cover_queued' => '已投递封面生成任务', 'upload_required' => '请上传附件文件。', 'mime_not_allowed' => '不允许的 MIME 类型::mime', 'theme_slots_warn' => '已启用,但槽位声明::label。请在 theme.json 中声明 slots。', @@ -170,6 +201,9 @@ return [ 'plugin_required_by' => '无法禁用 :plugin,仍被以下已启用插件依赖::dependents', 'plugin_docs_missing' => '未找到该插件的使用说明', 'paid_password_mutex' => '阅读密码与付费阅读不能同时启用', + 'access_restriction_mutex' => '阅读密码、单篇付费、会员可见不能同时启用', + 'membership_plan_in_use_articles' => '仍有文章引用此套餐,无法删除(可先禁用)', + 'membership_plan_has_entitlements' => '仍有用户权益绑定此套餐,无法删除(可先禁用)', ], 'pages' => [ @@ -212,6 +246,7 @@ return [ 'snippets' => '注入 / 广告 / 统计', ], 'paid_content' => '付费阅读', + 'membership' => '会员可见', 'snippet_groups' => [ 'analytics' => '统计与脚本', 'analytics_help' => '写入页面 head / body 末尾的统计与第三方脚本', @@ -355,7 +390,7 @@ return [ ], 'larablog/membership' => [ 'title' => '会员', - 'description' => '会员等级、权益与订阅周期占位。', + 'description' => '会员套餐、Stub 订阅与会员可见文章(依赖 payment)。', ], 'larablog/plugin-marketplace' => [ 'title' => '插件商城', diff --git a/lang/zh_CN/frontend.php b/lang/zh_CN/frontend.php index 711388c..8b80842 100644 --- a/lang/zh_CN/frontend.php +++ b/lang/zh_CN/frontend.php @@ -18,12 +18,28 @@ return [ 'subscribe' => '订阅', 'empty_category' => '暂无分类', ], + 'category' => [ + 'post_count' => ':count 篇文章', + ], 'article' => [ 'toc' => '目录', 'password_error' => '密码错误', 'paywall_title' => '付费内容', 'paywall_hint' => '购买后可阅读全文。', 'buy' => '立即购买', + 'membership_required' => '本文仅限会员阅读。', + ], + 'membership' => [ + 'title' => '会员套餐', + 'active' => '当前会员::plan', + 'inactive' => '你还不是付费会员。', + 'expires' => '到期::date', + 'lifetime' => '终身有效', + 'days' => ':days 天', + 'subscribe' => '订阅', + 'no_plans' => '暂无可用套餐。', + 'back_profile' => '返回资料', + 'manage' => '查看 / 订阅会员', ], 'common' => [ 'pinned' => '置顶', diff --git a/plugins/larablog/membership/README.md b/plugins/larablog/membership/README.md new file mode 100644 index 0000000..10a6097 --- /dev/null +++ b/plugins/larablog/membership/README.md @@ -0,0 +1,23 @@ +# larablog/membership + +Paid membership plans on top of `larablog/payment`. + +## Prerequisites + +1. Enable `larablog/payment`. +2. Enable this plugin. +3. `php artisan migrate` +4. Seed demo plans (optional): + +```bash +php artisan db:seed --class=Plugins\\Larablog\\Membership\\Database\\Seeders\\MembershipPlanSeeder +``` + +## Usage + +- Admin → Plugins group → Membership plans +- Public: `/plugins/membership` +- Status JSON: `/plugins/membership/status` +- Mark an article as members-only in the article form (mutually exclusive with read password and paid-content) + +Checkout uses Stub payment: `/plugins/payment/checkout?product_type=membership&product_id={planId}`. diff --git a/plugins/larablog/membership/README.zh_CN.md b/plugins/larablog/membership/README.zh_CN.md new file mode 100644 index 0000000..a44073b --- /dev/null +++ b/plugins/larablog/membership/README.zh_CN.md @@ -0,0 +1,28 @@ +# 会员(`larablog/membership`) + +在「支付」插件之上提供付费会员套餐,以及会员可见文章。 + +## 使用前准备 + +1. 启用 `larablog/payment`。 +2. 启用本插件。 +3. 执行迁移: + +```bash +php artisan migrate +``` + +4. 可选:写入演示套餐 + +```bash +php artisan db:seed --class=Plugins\\Larablog\\Membership\\Database\\Seeders\\MembershipPlanSeeder +``` + +## 使用 + +- 后台 → 插件分组 → 会员套餐 +- 前台套餐页:`/plugins/membership` +- 当前会员状态 JSON:`/plugins/membership/status` +- 在文章表单中把文章标为「会员可见」(与阅读密码、单篇付费互斥) + +结账走 Stub 支付:`/plugins/payment/checkout?product_type=membership&product_id={套餐ID}`。 diff --git a/plugins/larablog/membership/database/migrations/2026_08_12_034600_create_membership_plans_table.php b/plugins/larablog/membership/database/migrations/2026_08_12_034600_create_membership_plans_table.php new file mode 100644 index 0000000..a056821 --- /dev/null +++ b/plugins/larablog/membership/database/migrations/2026_08_12_034600_create_membership_plans_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('slug')->unique(); + $table->string('name'); + $table->text('description')->nullable(); + $table->decimal('price', 10, 2); + $table->string('currency', 8)->default('CNY'); + $table->unsignedInteger('duration_days')->nullable(); + $table->boolean('enabled')->default(true); + $table->integer('sort_order')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('membership_plans'); + } +}; diff --git a/plugins/larablog/membership/database/migrations/2026_08_12_034601_create_article_membership_table.php b/plugins/larablog/membership/database/migrations/2026_08_12_034601_create_article_membership_table.php new file mode 100644 index 0000000..c84f3bc --- /dev/null +++ b/plugins/larablog/membership/database/migrations/2026_08_12_034601_create_article_membership_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('article_id')->unique()->constrained('articles')->cascadeOnDelete(); + $table->boolean('enabled')->default(false); + $table->foreignId('required_plan_id') + ->nullable() + ->constrained('membership_plans') + ->restrictOnDelete(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('article_membership'); + } +}; diff --git a/plugins/larablog/membership/plugin.json b/plugins/larablog/membership/plugin.json index c93376a..cb10116 100644 --- a/plugins/larablog/membership/plugin.json +++ b/plugins/larablog/membership/plugin.json @@ -1,7 +1,9 @@ { - "name": "larablog/membership", - "title": "Membership", - "version": "1.0.0", - "description": "Membership plugin skeleton.", - "provider": "Plugins\\Larablog\\Membership\\PluginServiceProvider" + "name": "larablog/membership", + "title": "Membership", + "version": "1.1.0", + "description": "Membership plans, stub subscribe via payment, and members-only articles.", + "provider": "Plugins\\Larablog\\Membership\\PluginServiceProvider", + "requires": ["larablog/payment"], + "docs": "README.md" } diff --git a/plugins/larablog/membership/resources/views/plans.blade.php b/plugins/larablog/membership/resources/views/plans.blade.php new file mode 100644 index 0000000..fe6550f --- /dev/null +++ b/plugins/larablog/membership/resources/views/plans.blade.php @@ -0,0 +1,57 @@ + + + + + + {{ __('frontend.membership.title') }} + + + +

{{ __('frontend.membership.title') }}

+ +
+ @if(($status['active'] ?? false)) +

{{ __('frontend.membership.active', ['plan' => $status['plan_name'] ?? '—']) }}

+ @if(!empty($status['expires_at'])) +

{{ __('frontend.membership.expires', ['date' => $status['expires_at']]) }}

+ @else +

{{ __('frontend.membership.lifetime') }}

+ @endif + @else +

{{ __('frontend.membership.inactive') }}

+ @endif +

{{ __('frontend.membership.back_profile') }}

+
+ + @forelse($plans as $plan) +
+

{{ $plan->name }}

+

{{ $plan->currency }} {{ $plan->price }}

+

+ @if($plan->duration_days) + {{ __('frontend.membership.days', ['days' => $plan->duration_days]) }} + @else + {{ __('frontend.membership.lifetime') }} + @endif +

+ @if($plan->description) +

{{ $plan->description }}

+ @endif + {{ __('frontend.membership.subscribe') }} +
+ @empty +

{{ __('frontend.membership.no_plans') }}

+ @endforelse + + diff --git a/plugins/larablog/membership/src/Database/Seeders/MembershipPlanSeeder.php b/plugins/larablog/membership/src/Database/Seeders/MembershipPlanSeeder.php new file mode 100644 index 0000000..5bf1b2a --- /dev/null +++ b/plugins/larablog/membership/src/Database/Seeders/MembershipPlanSeeder.php @@ -0,0 +1,40 @@ +updateOrCreate( + ['slug' => 'monthly'], + [ + 'name' => '月度会员', + 'description' => '30 天会员,可阅读会员可见文章。', + 'price' => '9.90', + 'currency' => 'CNY', + 'duration_days' => 30, + 'enabled' => true, + 'sort_order' => 10, + ], + ); + + MembershipPlan::query()->updateOrCreate( + ['slug' => 'lifetime'], + [ + 'name' => '终身会员', + 'description' => '一次购买,长期有效。', + 'price' => '99.00', + 'currency' => 'CNY', + 'duration_days' => null, + 'enabled' => true, + 'sort_order' => 20, + ], + ); + } +} diff --git a/plugins/larablog/membership/src/Domain/MembershipService.php b/plugins/larablog/membership/src/Domain/MembershipService.php new file mode 100644 index 0000000..35e03d7 --- /dev/null +++ b/plugins/larablog/membership/src/Domain/MembershipService.php @@ -0,0 +1,89 @@ +orders->hasActiveAny((int) $user->id, ProductType::MEMBERSHIP); + } + + public function hasPlan(?User $user, int $planId): bool + { + if ($user === null) { + return false; + } + + return $this->orders->hasEntitlement((int) $user->id, ProductType::MEMBERSHIP, $planId); + } + + /** + * @return array{active: bool, plan_id: ?int, plan_slug: ?string, plan_name: ?string, expires_at: ?string} + */ + public function statusFor(?User $user): array + { + if ($user === null) { + return [ + 'active' => false, + 'plan_id' => null, + 'plan_slug' => null, + 'plan_name' => null, + 'expires_at' => null, + ]; + } + + $entitlement = Entitlement::query() + ->active() + ->where('user_id', $user->id) + ->where('product_type', ProductType::MEMBERSHIP) + ->orderByRaw('expires_at is null desc') + ->orderByDesc('expires_at') + ->first(); + + if ($entitlement === null) { + return [ + 'active' => false, + 'plan_id' => null, + 'plan_slug' => null, + 'plan_name' => null, + 'expires_at' => null, + ]; + } + + $plan = MembershipPlan::query()->find($entitlement->product_id); + + return [ + 'active' => true, + 'plan_id' => (int) $entitlement->product_id, + 'plan_slug' => $plan?->slug, + 'plan_name' => $plan?->name, + 'expires_at' => optional($entitlement->expires_at)?->toIso8601String(), + ]; + } + + public function cheapestEnabledPlan(): ?MembershipPlan + { + return MembershipPlan::query() + ->enabled() + ->orderBy('price') + ->orderBy('sort_order') + ->first(); + } +} diff --git a/plugins/larablog/membership/src/Filament/Resources/MembershipPlanResource.php b/plugins/larablog/membership/src/Filament/Resources/MembershipPlanResource.php new file mode 100644 index 0000000..c240ff3 --- /dev/null +++ b/plugins/larablog/membership/src/Filament/Resources/MembershipPlanResource.php @@ -0,0 +1,134 @@ +isEnabled('larablog/membership'); + } + + public static function form(Schema $schema): Schema + { + return $schema->components([ + TextInput::make('slug')->label(__('admin.fields.slug'))->required()->unique(ignoreRecord: true), + TextInput::make('name')->label(__('admin.fields.name'))->required(), + Textarea::make('description')->label(__('admin.fields.description'))->columnSpanFull(), + TextInput::make('price')->label(__('admin.fields.price'))->numeric()->required(), + TextInput::make('currency')->label(__('admin.fields.currency'))->default('CNY')->required(), + TextInput::make('duration_days') + ->label(__('admin.fields.duration_days')) + ->numeric() + ->helperText(__('admin.helpers.duration_days')), + TextInput::make('sort_order')->label(__('admin.fields.display_order'))->numeric()->default(0), + Toggle::make('enabled')->label(__('admin.fields.enabled'))->default(true), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + AdminTable::ellipsis( + TextColumn::make('name')->label(__('admin.fields.name'))->searchable(), + ), + TextColumn::make('slug')->label(__('admin.fields.slug')), + TextColumn::make('price')->label(__('admin.fields.price')), + TextColumn::make('duration_days') + ->label(__('admin.fields.duration_days')) + ->formatStateUsing(fn ($state) => $state === null ? __('admin.options.lifetime') : (string) $state), + IconColumn::make('enabled')->label(__('admin.fields.enabled'))->boolean(), + TextColumn::make('sort_order')->label(__('admin.fields.display_order')), + ]) + ->recordActions([ + EditAction::make(), + DeleteAction::make() + ->before(function (MembershipPlan $record, DeleteAction $action): void { + if ($record->articleGates()->exists()) { + Notification::make() + ->title(__('admin.messages.membership_plan_in_use_articles')) + ->danger() + ->send(); + $action->cancel(); + } + + if ($record->hasEntitlements()) { + Notification::make() + ->title(__('admin.messages.membership_plan_has_entitlements')) + ->danger() + ->send(); + $action->cancel(); + } + }), + ]) + ->defaultSort('sort_order'); + } + + public static function getPages(): array + { + return [ + 'index' => ListMembershipPlans::route('/'), + 'create' => CreateMembershipPlan::route('/create'), + 'edit' => EditMembershipPlan::route('/{record}/edit'), + ]; + } +} diff --git a/plugins/larablog/membership/src/Filament/Resources/MembershipPlanResource/Pages/CreateMembershipPlan.php b/plugins/larablog/membership/src/Filament/Resources/MembershipPlanResource/Pages/CreateMembershipPlan.php new file mode 100644 index 0000000..265612e --- /dev/null +++ b/plugins/larablog/membership/src/Filament/Resources/MembershipPlanResource/Pages/CreateMembershipPlan.php @@ -0,0 +1,13 @@ +before(function (DeleteAction $action): void { + /** @var MembershipPlan $record */ + $record = $this->getRecord(); + + if ($record->articleGates()->exists()) { + Notification::make() + ->title(__('admin.messages.membership_plan_in_use_articles')) + ->danger() + ->send(); + $action->cancel(); + } + + if ($record->hasEntitlements()) { + Notification::make() + ->title(__('admin.messages.membership_plan_has_entitlements')) + ->danger() + ->send(); + $action->cancel(); + } + }), + ]; + } +} diff --git a/plugins/larablog/membership/src/Filament/Resources/MembershipPlanResource/Pages/ListMembershipPlans.php b/plugins/larablog/membership/src/Filament/Resources/MembershipPlanResource/Pages/ListMembershipPlans.php new file mode 100644 index 0000000..4e334bd --- /dev/null +++ b/plugins/larablog/membership/src/Filament/Resources/MembershipPlanResource/Pages/ListMembershipPlans.php @@ -0,0 +1,21 @@ + 'boolean', + 'required_plan_id' => 'integer', + ]; + } + + public function article(): BelongsTo + { + return $this->belongsTo(Article::class); + } + + public function requiredPlan(): BelongsTo + { + return $this->belongsTo(MembershipPlan::class, 'required_plan_id'); + } +} diff --git a/plugins/larablog/membership/src/Models/MembershipPlan.php b/plugins/larablog/membership/src/Models/MembershipPlan.php new file mode 100644 index 0000000..9c44e98 --- /dev/null +++ b/plugins/larablog/membership/src/Models/MembershipPlan.php @@ -0,0 +1,58 @@ + 'decimal:2', + 'duration_days' => 'integer', + 'enabled' => 'boolean', + 'sort_order' => 'integer', + ]; + } + + public function articleGates(): HasMany + { + return $this->hasMany(ArticleMembership::class, 'required_plan_id'); + } + + public function scopeEnabled(Builder $query): Builder + { + return $query->where('enabled', true); + } + + public function isLifetime(): bool + { + return $this->duration_days === null; + } + + public function hasEntitlements(): bool + { + return Entitlement::query() + ->where('product_type', ProductType::MEMBERSHIP) + ->where('product_id', $this->id) + ->exists(); + } +} diff --git a/plugins/larablog/membership/src/PluginServiceProvider.php b/plugins/larablog/membership/src/PluginServiceProvider.php index 4619921..799c9a9 100644 --- a/plugins/larablog/membership/src/PluginServiceProvider.php +++ b/plugins/larablog/membership/src/PluginServiceProvider.php @@ -1,39 +1,300 @@ app->singleton(MembershipService::class); + + Panel::configureUsing(function (Panel $panel): void { + if ($panel->getId() !== 'admin') { + return; + } + + $panel->resources([MembershipPlanResource::class]); + }); } public function boot(): void { - Hook::listen('theme.sidebar', function (string $html): string { - $title = __('admin.plugins.larablog/membership.title'); - $desc = __('admin.plugins.larablog/membership.description'); + $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); + $this->loadViewsFrom(__DIR__.'/../resources/views', 'membership'); - return $html.'

'.e($title).'

'.e($desc).' /plugins/membership/status

'; - }); + $this->registerWebRoutes(); + $this->registerFilamentHooks(); + $this->registerAccessHook(); + $this->registerOrderPaidHook(); + } + protected function registerWebRoutes(): void + { Route::middleware('web')->prefix('plugins/membership')->group(function (): void { + Route::get('/', function () { + $plans = MembershipPlan::query() + ->enabled() + ->orderBy('sort_order') + ->orderBy('id') + ->get(); + + return view('membership::plans', [ + 'plans' => $plans, + 'status' => app(MembershipService::class)->statusFor(Auth::user()), + ]); + })->name('plugins.membership.plans'); + Route::get('/status', function () { - $user = auth()->user(); + $user = Auth::user(); + $status = app(MembershipService::class)->statusFor($user); return response()->json([ 'ok' => true, 'plugin' => 'larablog/membership', 'authenticated' => $user !== null, 'roles' => $user?->getRoleNames() ?? [], - 'message' => 'Membership skeleton. Billing tiers come in phase 2.', + ...$status, ]); - }); + })->name('plugins.membership.status'); }); } + + protected function registerFilamentHooks(): void + { + Hook::listen('filament.article.form', function (array $components): array { + if (! app(PluginManager::class)->isEnabled('larablog/membership')) { + return []; + } + + return [ + Section::make(__('admin.settings.membership')) + ->schema([ + Toggle::make('membership.enabled') + ->label(__('admin.fields.membership_enabled')) + ->default(false), + Select::make('membership.required_plan_id') + ->label(__('admin.fields.required_plan')) + ->options( + MembershipPlan::query() + ->orderBy('sort_order') + ->pluck('name', 'id') + ->all() + ) + ->placeholder(__('admin.options.any_membership')) + ->nullable(), + ]) + ->collapsible(), + ]; + }); + + Hook::listen('filament.article.mutate_before_fill', function (array $data, mixed $record): array { + if (! $record instanceof Article) { + return $data; + } + + $row = ArticleMembership::query()->where('article_id', $record->id)->first(); + $data['membership'] = [ + 'enabled' => (bool) ($row?->enabled ?? false), + 'required_plan_id' => $row?->required_plan_id, + ]; + + return $data; + }); + + Hook::listen('filament.article.validate_access_restrictions', function (array $data, mixed $record): array { + $membership = is_array($data['membership'] ?? null) ? $data['membership'] : []; + $membershipEnabled = (bool) ($membership['enabled'] ?? false); + $paid = is_array($data['paid_content'] ?? null) ? $data['paid_content'] : []; + $paidEnabled = (bool) ($paid['enabled'] ?? false); + $hasPassword = filled($data['read_password'] ?? null); + + $flags = array_filter([ + 'password' => $hasPassword, + 'paid' => $paidEnabled, + 'membership' => $membershipEnabled, + ]); + + if (count($flags) > 1) { + throw ValidationException::withMessages([ + 'read_password' => __('admin.messages.access_restriction_mutex'), + 'paid_content.enabled' => __('admin.messages.access_restriction_mutex'), + 'membership.enabled' => __('admin.messages.access_restriction_mutex'), + ]); + } + + return $data; + }); + + Hook::listen('filament.article.after_save', function (Article $record, array $data): void { + $membership = $data['membership'] ?? null; + if (! is_array($membership)) { + return; + } + + ArticleMembership::query()->updateOrCreate( + ['article_id' => $record->id], + [ + 'enabled' => (bool) ($membership['enabled'] ?? false), + 'required_plan_id' => filled($membership['required_plan_id'] ?? null) + ? (int) $membership['required_plan_id'] + : null, + ], + ); + }); + + Hook::listen('filament.article.table.columns', function (array $columns): array { + return [ + IconColumn::make('membership_enabled') + ->label(__('admin.fields.membership_enabled')) + ->boolean() + ->getStateUsing(function (Article $record): bool { + return ArticleMembership::query() + ->where('article_id', $record->id) + ->where('enabled', true) + ->exists(); + }), + ]; + }); + } + + protected function registerAccessHook(): void + { + Hook::listen('article.access', function (AccessDecision $decision, array $context): AccessDecision { + if (! app(PluginManager::class)->isEnabled('larablog/membership')) { + return $decision; + } + + $article = $context['article'] ?? null; + $user = $context['user'] ?? null; + + if (! $article instanceof Article) { + return $decision; + } + + $gate = ArticleMembership::query() + ->where('article_id', $article->id) + ->where('enabled', true) + ->first(); + + if ($gate === null) { + return $decision; + } + + $service = app(MembershipService::class); + $allowed = $gate->required_plan_id + ? $service->hasPlan($user instanceof User ? $user : null, (int) $gate->required_plan_id) + : $service->isActive($user instanceof User ? $user : null); + + if ($allowed) { + return $decision; + } + + $teaser = app(HtmlTeaser::class)->truncate($article->renderedHtml(), 200); + $checkoutUrl = $this->checkoutUrlForGate($gate, $article); + + return $decision->tightenWith(AccessDecision::needPurchase( + $teaser, + $checkoutUrl, + __('frontend.article.membership_required'), + )); + }); + } + + protected function registerOrderPaidHook(): void + { + Hook::listen('order.paid', function (Order $order): void { + $order->loadMissing('items'); + + foreach ($order->items as $item) { + if ($item->product_type !== ProductType::MEMBERSHIP) { + continue; + } + + $plan = MembershipPlan::query()->find($item->product_id); + $entitlement = Entitlement::query() + ->where('user_id', $order->user_id) + ->where('product_type', ProductType::MEMBERSHIP) + ->where('product_id', $item->product_id) + ->first(); + + if ($entitlement === null) { + continue; + } + + if ($plan === null) { + // Fail-closed: never turn a missing plan into a lifetime grant. + $entitlement->forceFill([ + 'revoked_at' => now(), + 'expires_at' => null, + ])->save(); + + Log::warning('Membership order.paid revoked: plan missing.', [ + 'order_id' => $order->id, + 'plan_id' => $item->product_id, + ]); + + continue; + } + + $entitlement->forceFill([ + 'expires_at' => $plan->duration_days !== null + ? now()->addDays((int) $plan->duration_days) + : null, + 'revoked_at' => null, + ])->save(); + } + }); + } + + protected function checkoutUrlForGate(ArticleMembership $gate, Article $article): ?string + { + $plan = null; + + if ($gate->required_plan_id) { + $plan = MembershipPlan::query() + ->whereKey($gate->required_plan_id) + ->where('enabled', true) + ->first(); + } + + $plan ??= app(MembershipService::class)->cheapestEnabledPlan(); + + if ($plan === null) { + return null; + } + + $query = http_build_query([ + 'product_type' => ProductType::MEMBERSHIP, + 'product_id' => $plan->id, + 'return_url' => url('/show-'.$article->id.'.shtml'), + ]); + + return url('/plugins/payment/checkout').'?'.$query; + } } diff --git a/plugins/larablog/paid-content/README.zh_CN.md b/plugins/larablog/paid-content/README.zh_CN.md new file mode 100644 index 0000000..6f6d648 --- /dev/null +++ b/plugins/larablog/paid-content/README.zh_CN.md @@ -0,0 +1,35 @@ +# 付费内容(`larablog/paid-content`) + +按篇售卖文章:未购买的读者只能看到试读,结账走「支付」插件(`larablog/payment`)。 + +## 使用前准备 + +1. 先启用 **支付**(`larablog/payment`)。本插件把它列为硬依赖。 +2. 执行迁移,创建 `article_products` 以及支付相关表: + +```bash +php artisan migrate +``` + +3. 在后台 → 插件 中启用 **付费内容**。 + +## 给文章定价 + +1. 打开后台 → 文章 → 新建 / 编辑。 +2. 在 **付费内容** 区块中: + - 打开付费开关 + - 填写价格与货币(默认 `CNY`) + - 设置试读字数(默认 `200`;按渲染后的 HTML 文本截取) +3. 保存。 + +**注意:** 阅读密码与付费内容不能同时开启。私下分享用密码,对外售卖用付费。 + +## 前台效果 + +- 未购买的读者看到试读和购买按钮。 +- 结账地址为 `/plugins/payment/checkout`。 +- 已登录且已购买的读者、文章作者、以及拥有 `admin` 角色的用户可以看到全文。 + +## 停用 + +在插件列表中禁用本插件后,文章表单里的付费区块、列表列和前台付费墙会撤掉。已有的 `article_products` 记录不会自动删除,需要时请自行清理。 diff --git a/plugins/larablog/paid-content/src/PluginServiceProvider.php b/plugins/larablog/paid-content/src/PluginServiceProvider.php index 4b5c476..bb101e1 100644 --- a/plugins/larablog/paid-content/src/PluginServiceProvider.php +++ b/plugins/larablog/paid-content/src/PluginServiceProvider.php @@ -13,6 +13,9 @@ use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Schemas\Components\Section; use Filament\Tables\Columns\IconColumn; +use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Filters\TernaryFilter; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\ValidationException; use Plugins\Larablog\PaidContent\Models\ArticleProduct; @@ -72,19 +75,27 @@ class PluginServiceProvider extends ServiceProvider return $data; }); - Hook::listen('filament.article.mutate_before_save', function (array $data, mixed $record): array { + Hook::listen('filament.article.validate_access_restrictions', function (array $data, mixed $record): array { $paid = is_array($data['paid_content'] ?? null) ? $data['paid_content'] : []; - $enabled = (bool) ($paid['enabled'] ?? false); + $paidEnabled = (bool) ($paid['enabled'] ?? false); + $membership = is_array($data['membership'] ?? null) ? $data['membership'] : []; + $membershipEnabled = (bool) ($membership['enabled'] ?? false); + $hasPassword = filled($data['read_password'] ?? null); - if ($enabled && filled($data['read_password'] ?? null)) { + $flags = array_filter([ + 'password' => $hasPassword, + 'paid' => $paidEnabled, + 'membership' => $membershipEnabled, + ]); + + if (count($flags) > 1) { throw ValidationException::withMessages([ - 'read_password' => __('admin.messages.paid_password_mutex'), - 'paid_content.enabled' => __('admin.messages.paid_password_mutex'), + 'read_password' => __('admin.messages.access_restriction_mutex'), + 'paid_content.enabled' => __('admin.messages.access_restriction_mutex'), + 'membership.enabled' => __('admin.messages.access_restriction_mutex'), ]); } - unset($data['paid_content']); - return $data; }); @@ -113,15 +124,60 @@ class PluginServiceProvider extends ServiceProvider IconColumn::make('paid_content_enabled') ->label(__('admin.fields.paid_enabled')) ->boolean() - ->getStateUsing(function (Article $record): bool { - return ArticleProduct::query() - ->where('article_id', $record->id) - ->where('enabled', true) - ->exists(); + ->getStateUsing(fn (Article $record): bool => filled($record->getAttribute('paid_content_price'))), + TextColumn::make('paid_content_price') + ->label(__('admin.fields.price')) + ->sortable() + ->formatStateUsing(function (mixed $state, Article $record): string { + if ($state === null || $state === '') { + return '—'; + } + + $currency = (string) ($record->getAttribute('paid_content_currency') ?: 'CNY'); + + return $state.' '.$currency; }), ]; }); + Hook::listen('filament.article.table.filters', function (array $filters): array { + return [ + TernaryFilter::make('paid_enabled') + ->label(__('admin.fields.paid_enabled')) + ->queries( + true: fn (Builder $query): Builder => $query->whereExists( + fn ($sub) => $sub->from('article_products') + ->whereColumn('article_products.article_id', 'articles.id') + ->where('enabled', true), + ), + false: fn (Builder $query): Builder => $query->whereNotExists( + fn ($sub) => $sub->from('article_products') + ->whereColumn('article_products.article_id', 'articles.id') + ->where('enabled', true), + ), + ), + ]; + }); + + Hook::listen('filament.article.table.query', function (mixed $query): mixed { + if (! $query instanceof Builder) { + return $query; + } + + return $query->addSelect([ + 'paid_content_price' => ArticleProduct::query() + ->select('price') + ->whereColumn('article_id', 'articles.id') + ->where('enabled', true) + ->limit(1), + 'paid_content_currency' => ArticleProduct::query() + ->select('currency') + ->whereColumn('article_id', 'articles.id') + ->where('enabled', true) + ->limit(1), + ]); + }); + Hook::listen('article.access', function (AccessDecision $decision, array $context): AccessDecision { $article = $context['article'] ?? null; $user = $context['user'] ?? null; diff --git a/plugins/larablog/payment/README.zh_CN.md b/plugins/larablog/payment/README.zh_CN.md new file mode 100644 index 0000000..6f57ef0 --- /dev/null +++ b/plugins/larablog/payment/README.zh_CN.md @@ -0,0 +1,50 @@ +# 支付(`larablog/payment`) + +支付基础能力:订单、流水、权益,以及后台订单工具。当前网关为 **Stub 模拟支付**,用于打通下单与开通流程。 + +## 启用 + +1. 从磁盘同步插件(`php artisan plugins:sync`,或后台 → 插件 → 同步磁盘插件)。 +2. 启用 **支付**(`larablog/payment`)。 +3. 执行迁移,创建插件表: + +```bash +php artisan migrate +``` + +会创建:`orders`、`order_items`、`entitlements`、`payment_transactions`。 + +## Stub 结账流程 + +必须先登录。 + +```text +GET /plugins/payment/checkout + ?product_type=article + &product_id=1 + &title=示例文章 + &amount=9.90 + ¤cy=CNY + &return_url=/ +``` + +流程: + +1. 创建一张 `pending` 订单(含一条明细);若该商品已有有效权益,则提示错误、不重复建单。 +2. 跳转到 `/plugins/payment/orders/{id}` 确认页。 +3. 点击 **模拟支付成功** → `POST .../pay`:标记已支付、写入流水、开通权益,并触发 `order.paid`。 +4. 跳回 `return_url`(仅允许本站同源地址),否则回首页。 + +## 后台 + +启用本插件后会出现: + +- **订单** — 查看订单列表与详情 +- **标记已支付** — 在待支付订单详情页 +- **支付说明** — 本页(插件使用说明) + +## 说明 + +- 本期网关固定为 `stub`。 +- `product_type` 预留:`article`、`theme`、`membership`。 +- 微信 / 支付宝 / Stripe 等真实网关不在本期范围。 diff --git a/plugins/larablog/payment/database/migrations/2026_08_12_034500_add_expires_at_to_entitlements_table.php b/plugins/larablog/payment/database/migrations/2026_08_12_034500_add_expires_at_to_entitlements_table.php new file mode 100644 index 0000000..aeb6cb5 --- /dev/null +++ b/plugins/larablog/payment/database/migrations/2026_08_12_034500_add_expires_at_to_entitlements_table.php @@ -0,0 +1,28 @@ +timestamp('expires_at')->nullable()->after('revoked_at'); + } + }); + } + + public function down(): void + { + Schema::table('entitlements', function (Blueprint $table): void { + if (Schema::hasColumn('entitlements', 'expires_at')) { + $table->dropColumn('expires_at'); + } + }); + } +}; diff --git a/plugins/larablog/payment/resources/views/filament/pages/payment-settings.blade.php b/plugins/larablog/payment/resources/views/filament/pages/payment-settings.blade.php index 770f56f..f99c1ab 100644 --- a/plugins/larablog/payment/resources/views/filament/pages/payment-settings.blade.php +++ b/plugins/larablog/payment/resources/views/filament/pages/payment-settings.blade.php @@ -4,8 +4,6 @@ :description="__('admin.plugins.larablog/payment.title')" icon="heroicon-o-credit-card" > -
- {{ $readmeExcerpt }} -
+ @include('filament.partials.plugin-docs', ['html' => $readmeHtml]) diff --git a/plugins/larablog/payment/src/Domain/OrderService.php b/plugins/larablog/payment/src/Domain/OrderService.php index 76917af..eb79dee 100644 --- a/plugins/larablog/payment/src/Domain/OrderService.php +++ b/plugins/larablog/payment/src/Domain/OrderService.php @@ -25,8 +25,6 @@ class OrderService string $gateway = 'stub', ): Order { return DB::transaction(function () use ($user, $productType, $productId, $title, $amount, $currency, $gateway): Order { - // Re-check inside the transaction: two concurrent checkouts must not - // both end up with a payable order for an already owned product. if ($this->hasEntitlement((int) $user->id, $productType, $productId)) { throw new RuntimeException( __('payment.errors.already_entitled', [ @@ -38,8 +36,6 @@ class OrderService $pending = $this->pendingOrderFor((int) $user->id, $productType, $productId); if ($pending !== null) { - // Reuse the order but re-price it, so an unpaid order never locks - // in a stale amount after the seller changes the price. $pending->forceFill([ 'amount' => $amount, 'currency' => $currency, @@ -73,9 +69,6 @@ class OrderService }); } - /** - * Reuse an existing payable order instead of stacking duplicates. - */ public function pendingOrderFor(int $userId, string $productType, int $productId): ?Order { return Order::query() @@ -119,14 +112,12 @@ class OrderService $order->loadMissing('items'); - // Refuse to charge again for something the buyer already owns from - // another order (duplicate pending orders, replayed callbacks). foreach ($order->items as $item) { $ownedElsewhere = Entitlement::query() + ->active() ->where('user_id', $order->user_id) ->where('product_type', $item->product_type) ->where('product_id', $item->product_id) - ->whereNull('revoked_at') ->where(function ($query) use ($order): void { $query->whereNull('source_order_id') ->orWhere('source_order_id', '!=', $order->id); @@ -158,8 +149,6 @@ class OrderService 'status' => 'succeeded', ]); - $order->loadMissing('items'); - foreach ($order->items as $item) { Entitlement::query()->updateOrCreate( [ @@ -171,6 +160,8 @@ class OrderService 'source_order_id' => $order->id, 'granted_at' => now(), 'revoked_at' => null, + // expires_at is set by product plugins via order.paid when needed. + 'expires_at' => null, ], ); } @@ -186,10 +177,19 @@ class OrderService public function hasEntitlement(int $userId, string $productType, int $productId): bool { return Entitlement::query() + ->active() ->where('user_id', $userId) ->where('product_type', $productType) ->where('product_id', $productId) - ->whereNull('revoked_at') + ->exists(); + } + + public function hasActiveAny(int $userId, string $productType): bool + { + return Entitlement::query() + ->active() + ->where('user_id', $userId) + ->where('product_type', $productType) ->exists(); } } diff --git a/plugins/larablog/payment/src/Filament/Pages/PaymentSettingsPage.php b/plugins/larablog/payment/src/Filament/Pages/PaymentSettingsPage.php index d4e3ec2..61dafa9 100644 --- a/plugins/larablog/payment/src/Filament/Pages/PaymentSettingsPage.php +++ b/plugins/larablog/payment/src/Filament/Pages/PaymentSettingsPage.php @@ -47,10 +47,8 @@ class PaymentSettingsPage extends Page */ public function getViewData(): array { - $docs = app(PluginManager::class)->readDocs('larablog/payment') ?? ''; - return [ - 'readmeExcerpt' => $docs !== '' ? $docs : __('admin.pages.payment_settings_empty'), + 'readmeHtml' => app(PluginManager::class)->readDocsHtml('larablog/payment') ?? '', ]; } } diff --git a/plugins/larablog/payment/src/Filament/Resources/OrderResource.php b/plugins/larablog/payment/src/Filament/Resources/OrderResource.php index 8c49679..be2ca9f 100644 --- a/plugins/larablog/payment/src/Filament/Resources/OrderResource.php +++ b/plugins/larablog/payment/src/Filament/Resources/OrderResource.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Plugins\Larablog\Payment\Filament\Resources; use App\Domain\Plugin\PluginManager; +use App\Filament\Support\AdminTable; use BackedEnum; use Filament\Actions\ViewAction; use Filament\Infolists\Components\TextEntry; @@ -109,9 +110,11 @@ class OrderResource extends Resource { return $table ->columns([ - TextColumn::make('id') - ->label(__('admin.fields.id')) - ->sortable(), + AdminTable::stickyStart( + TextColumn::make('id') + ->label(__('admin.fields.id')) + ->sortable(), + ), TextColumn::make('user.name') ->label(__('admin.fields.user')) ->searchable(), diff --git a/plugins/larablog/payment/src/Filament/Resources/OrderResource/Pages/ListOrders.php b/plugins/larablog/payment/src/Filament/Resources/OrderResource/Pages/ListOrders.php index 2d14c20..5c032e5 100644 --- a/plugins/larablog/payment/src/Filament/Resources/OrderResource/Pages/ListOrders.php +++ b/plugins/larablog/payment/src/Filament/Resources/OrderResource/Pages/ListOrders.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Plugins\Larablog\Payment\Filament\Resources\OrderResource\Pages; -use Filament\Resources\Pages\ListRecords; +use App\Filament\Resources\Pages\ListRecords; use Plugins\Larablog\Payment\Filament\Resources\OrderResource; class ListOrders extends ListRecords diff --git a/plugins/larablog/payment/src/Models/Entitlement.php b/plugins/larablog/payment/src/Models/Entitlement.php index 36b0906..0de03bc 100644 --- a/plugins/larablog/payment/src/Models/Entitlement.php +++ b/plugins/larablog/payment/src/Models/Entitlement.php @@ -18,6 +18,7 @@ class Entitlement extends Model 'source_order_id', 'granted_at', 'revoked_at', + 'expires_at', ]; protected function casts(): array @@ -27,6 +28,7 @@ class Entitlement extends Model 'source_order_id' => 'integer', 'granted_at' => 'datetime', 'revoked_at' => 'datetime', + 'expires_at' => 'datetime', ]; } @@ -42,11 +44,24 @@ class Entitlement extends Model public function scopeActive(Builder $query): Builder { - return $query->whereNull('revoked_at'); + return $query + ->whereNull('revoked_at') + ->where(function (Builder $inner): void { + $inner->whereNull('expires_at') + ->orWhere('expires_at', '>', now()); + }); } public function isActive(): bool { - return $this->revoked_at === null; + if ($this->revoked_at !== null) { + return false; + } + + if ($this->expires_at === null) { + return true; + } + + return $this->expires_at->isFuture(); } } diff --git a/plugins/larablog/payment/src/PluginServiceProvider.php b/plugins/larablog/payment/src/PluginServiceProvider.php index cf21ca8..5588beb 100644 --- a/plugins/larablog/payment/src/PluginServiceProvider.php +++ b/plugins/larablog/payment/src/PluginServiceProvider.php @@ -164,16 +164,30 @@ class PluginServiceProvider extends ServiceProvider ]; } - // Non-article stubs may still pass title/amount (theme/membership later). - $title = trim((string) $request->query('title', '')); - $amount = (string) $request->query('amount', ''); - $currency = (string) $request->query('currency', 'CNY'); + if ($productType === ProductType::MEMBERSHIP) { + $planClass = 'Plugins\\Larablog\\Membership\\Models\\MembershipPlan'; + if (! class_exists($planClass) || ! Schema::hasTable('membership_plans')) { + throw new RuntimeException(__('payment.errors.invalid_checkout')); + } - if ($title === '' || $amount === '') { - throw new RuntimeException(__('payment.errors.invalid_checkout')); + $plan = $planClass::query() + ->whereKey($productId) + ->where('enabled', true) + ->first(); + + if ($plan === null) { + throw new RuntimeException(__('payment.errors.invalid_checkout')); + } + + return [ + (string) $plan->name, + (string) $plan->price, + (string) ($plan->currency ?: 'CNY'), + ]; } - return [$title, $amount, $currency !== '' ? $currency : 'CNY']; + // Never trust client-supplied title/amount for unknown product types. + throw new RuntimeException(__('payment.errors.invalid_checkout')); } protected static function safeReturnUrl(mixed $value): string diff --git a/public/css/larablog-admin.css b/public/css/larablog-admin.css new file mode 100644 index 0000000..9d87c40 --- /dev/null +++ b/public/css/larablog-admin.css @@ -0,0 +1,312 @@ +/* LaraBlog admin table density + filter / sticky helpers */ + +/* —— Filters: full-width row, inline labels, wrap, Apply+Reset together —— */ +.lb-admin-table .fi-ta-filters-above-content-ctn { + padding-block: 0.75rem; +} + +.lb-admin-table .fi-ta-filters-above-content-ctn .fi-ta-filters-header { + display: none; +} + +.lb-admin-table .fi-ta-filters-above-content-ctn .fi-ta-filters { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 0.625rem 0.875rem; +} + +.lb-admin-table .fi-ta-filters-above-content-ctn .fi-sc { + display: flex !important; + flex-wrap: wrap; + align-items: center; + gap: 0.625rem 0.875rem; + flex: 1 1 auto; + min-width: 0; +} + +.lb-admin-table .fi-ta-filters-above-content-ctn .fi-grid-col { + flex: 0 1 auto; + width: auto !important; + max-width: none !important; + min-width: 11rem; +} + +.lb-admin-table .fi-ta-filters-above-content-ctn .fi-fo-field-has-inline-label { + gap: 0.5rem; +} + +.lb-admin-table .fi-ta-filters-above-content-ctn .fi-fo-field-has-inline-label > .fi-fo-field-label-ctn { + flex: 0 0 auto; + width: auto !important; + max-width: none !important; + padding-block: 0; +} + +.lb-admin-table .fi-ta-filters-above-content-ctn .fi-ta-filters-actions-ctn { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 0.5rem; + margin: 0; +} + +/* —— Slightly denser rows (between Filament default and ultra-compact) —— */ +.lb-admin-table .fi-ta-header-cell { + padding-block: 0.7rem; +} + +.lb-admin-table .fi-ta-text:not(.fi-inline), +.lb-admin-table .fi-ta-icon, +.lb-admin-table .fi-ta-image, +.lb-admin-table .fi-ta-color, +.lb-admin-table .fi-ta-cell:has(.fi-ta-actions), +.lb-admin-table .fi-ta-cell:has(.fi-ta-record-checkbox), +.lb-admin-table .fi-ta-selection-cell { + padding-block: 0.7rem; +} + +/* —— Sticky selection (left) + actions (right, fixed width) —— */ +.lb-admin-table .fi-ta-table { + border-collapse: separate; + border-spacing: 0; +} + +.lb-admin-table .fi-ta-selection-cell { + position: sticky; + left: 0; + z-index: 2; + background-color: #fff; + box-shadow: 4px 0 8px -6px rgba(15, 23, 42, 0.18); +} + +.lb-admin-table .fi-ta-selection-cell.lb-sticky-col-start, +.lb-admin-table .lb-sticky-col-start { + position: sticky; + z-index: 2; + background-color: #fff; + box-shadow: 4px 0 8px -6px rgba(15, 23, 42, 0.18); +} + +.lb-admin-table .lb-sticky-col-start { + left: 0; +} + +.lb-admin-table .fi-ta-selection-cell + .lb-sticky-col-start, +.lb-admin-table th.fi-ta-selection-cell + th.lb-sticky-col-start, +.lb-admin-table td.fi-ta-selection-cell + td.lb-sticky-col-start { + left: 3.25rem; +} + +.lb-admin-table .fi-ta-cell:has(.fi-ta-actions), +.lb-admin-table .fi-ta-actions-header-cell { + position: sticky; + right: 0; + z-index: 2; + width: 7.5rem; + min-width: 7.5rem; + max-width: 7.5rem; + background-color: #fff; + box-shadow: -4px 0 8px -6px rgba(15, 23, 42, 0.18); +} + +.lb-admin-table .fi-ta-cell:has(.fi-ta-actions) .fi-ta-actions { + flex-wrap: nowrap; +} + +html.dark .lb-admin-table .fi-ta-selection-cell, +html.dark .lb-admin-table .lb-sticky-col-start, +html.dark .lb-admin-table .fi-ta-cell:has(.fi-ta-actions), +html.dark .lb-admin-table .fi-ta-actions-header-cell { + background-color: #101828; +} + +/* Selected row backgrounds stay coherent under sticky cells */ +.lb-admin-table .fi-selected .fi-ta-selection-cell, +.lb-admin-table .fi-selected .lb-sticky-col-start, +.lb-admin-table .fi-selected .fi-ta-cell:has(.fi-ta-actions) { + background-color: color-mix(in srgb, var(--color-primary-50, #f0fdfa) 80%, #fff); +} + +html.dark .lb-admin-table .fi-selected .fi-ta-selection-cell, +html.dark .lb-admin-table .fi-selected .lb-sticky-col-start, +html.dark .lb-admin-table .fi-selected .fi-ta-cell:has(.fi-ta-actions) { + background-color: color-mix(in srgb, var(--color-primary-950, #042f2e) 55%, #101828); +} + +/* Centered plugin-docs modal: scroll the body, keep header/footer on screen */ +.lb-plugin-docs-modal.fi-modal-window { + max-height: min(90vh, 56rem); + display: flex; + flex-direction: column; +} + +.lb-plugin-docs-modal .fi-modal-content { + min-height: 0; + flex: 1 1 auto; + overflow-y: auto; +} + +/* Plugin README rendered as HTML (not a notification dump) */ +.lb-plugin-docs { + max-width: none; + font-size: 0.9375rem; + line-height: 1.7; + color: #334155; +} + +.lb-plugin-docs > *:first-child { + margin-top: 0; +} + +.lb-plugin-docs h1, +.lb-plugin-docs h2, +.lb-plugin-docs h3, +.lb-plugin-docs h4 { + color: #0f172a; + line-height: 1.35; +} + +.lb-plugin-docs h1 { + font-size: 1.35rem; + font-weight: 700; + margin: 0 0 0.85rem; +} + +.lb-plugin-docs h2 { + font-size: 1.12rem; + font-weight: 650; + margin: 1.5rem 0 0.55rem; +} + +.lb-plugin-docs h3, +.lb-plugin-docs h4 { + font-size: 1rem; + font-weight: 600; + margin: 1.15rem 0 0.4rem; +} + +.lb-plugin-docs p, +.lb-plugin-docs ul, +.lb-plugin-docs ol, +.lb-plugin-docs pre, +.lb-plugin-docs table, +.lb-plugin-docs blockquote { + margin: 0 0 0.85rem; +} + +.lb-plugin-docs ul, +.lb-plugin-docs ol { + padding-left: 1.35rem; +} + +.lb-plugin-docs li { + margin: 0.22rem 0; +} + +.lb-plugin-docs li > p { + margin: 0; +} + +.lb-plugin-docs a { + color: #0f766e; + text-decoration: underline; + text-underline-offset: 2px; +} + +.lb-plugin-docs strong { + font-weight: 650; + color: #0f172a; +} + +.lb-plugin-docs code { + font-size: 0.84em; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + background: #f1f5f9; + padding: 0.12em 0.38em; + border-radius: 0.28rem; +} + +.lb-plugin-docs pre { + overflow-x: auto; + background: #0f172a; + color: #e2e8f0; + padding: 0.9rem 1rem; + border-radius: 0.55rem; +} + +.lb-plugin-docs pre code { + background: transparent; + padding: 0; + color: inherit; + font-size: 0.82rem; +} + +.lb-plugin-docs table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; +} + +.lb-plugin-docs th, +.lb-plugin-docs td { + border: 1px solid #e2e8f0; + padding: 0.4rem 0.55rem; + text-align: left; + vertical-align: top; +} + +.lb-plugin-docs th { + background: #f8fafc; + font-weight: 600; +} + +.lb-plugin-docs blockquote { + border-left: 3px solid #99f6e4; + padding: 0.15rem 0 0.15rem 0.85rem; + color: #475569; +} + +.lb-plugin-docs hr { + margin: 1.25rem 0; + border: 0; + border-top: 1px solid #e2e8f0; +} + +html.dark .lb-plugin-docs { + color: #cbd5e1; +} + +html.dark .lb-plugin-docs h1, +html.dark .lb-plugin-docs h2, +html.dark .lb-plugin-docs h3, +html.dark .lb-plugin-docs h4, +html.dark .lb-plugin-docs strong { + color: #f8fafc; +} + +html.dark .lb-plugin-docs a { + color: #5eead4; +} + +html.dark .lb-plugin-docs code { + background: #1e293b; +} + +html.dark .lb-plugin-docs th, +html.dark .lb-plugin-docs td { + border-color: #334155; +} + +html.dark .lb-plugin-docs th { + background: #1e293b; +} + +html.dark .lb-plugin-docs blockquote { + border-left-color: #134e4a; + color: #94a3b8; +} + +html.dark .lb-plugin-docs hr { + border-top-color: #334155; +} diff --git a/public/themes/default/style.css b/public/themes/default/style.css index a57dd25..e3408f2 100644 --- a/public/themes/default/style.css +++ b/public/themes/default/style.css @@ -129,6 +129,32 @@ a:hover { color: var(--teal); } } .post-list { display: grid; gap: 0; } +.post-item__cover { + display: block; + margin: 0 0 0.75rem; + border-radius: 4px; + overflow: hidden; + aspect-ratio: 16 / 9; + background: #e8eef2; +} +.post-item__cover img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.post__cover { + margin: 0 0 1.25rem; + border-radius: 4px; + overflow: hidden; + background: #e8eef2; +} +.post__cover img { + width: 100%; + max-height: 420px; + object-fit: cover; + display: block; +} .post-item { padding: 1.35rem 0; border-bottom: 1px solid var(--line); @@ -148,6 +174,24 @@ a:hover { color: var(--teal); } .meta { color: var(--muted); font-size: 0.92rem; margin: 0 0 0.55rem; } .excerpt { margin: 0; color: #243442; } +.category-hero { margin-bottom: 1.25rem; } +.category-hero__cover { + margin: 0 0 1rem; + border-radius: calc(var(--radius) - 0.35rem); + overflow: hidden; +} +.category-hero__cover img { + width: 100%; + max-height: 240px; + object-fit: cover; + display: block; +} +.category-hero__intro { + margin: 0 0 0.75rem; + color: #243442; + line-height: 1.7; +} + .page-title { font-family: var(--serif); font-size: clamp(1.8rem, 3vw, 2.4rem); @@ -212,6 +256,26 @@ a:hover { color: var(--teal); } .post__toc-item--h3 { padding-left: 0.85rem; } .post__toc-item--h4 { padding-left: 1.7rem; } +.post__paywall { + margin-top: 1.5rem; + padding: 1.1rem 1.25rem; + border: 1px solid rgba(15, 138, 122, 0.28); + background: rgba(15, 138, 122, 0.06); +} +.post__paywall-title { + margin: 0 0 0.4rem; + font-size: 1.1rem; +} +.post__paywall .button { + display: inline-block; + margin-top: 0.35rem; + padding: 0.55rem 1rem; + border-radius: 0.4rem; + background: var(--teal-deep, #0f8a7a); + color: #fff; + text-decoration: none; +} + .post__content { font-size: 1.08rem; line-height: 1.8; @@ -272,6 +336,8 @@ a:hover { color: var(--teal); } margin-bottom: 0.35rem; } .comment__author { color: var(--ink); } +a.comment__author { text-decoration: none; } +a.comment__author:hover { color: var(--teal); } .comment__body { color: #243442; } .article-body .body { font-size: 1.05rem; } diff --git a/resources/fonts/.gitkeep b/resources/fonts/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/resources/fonts/.gitkeep @@ -0,0 +1 @@ + diff --git a/resources/fonts/README.md b/resources/fonts/README.md new file mode 100644 index 0000000..987c02e --- /dev/null +++ b/resources/fonts/README.md @@ -0,0 +1,5 @@ +# Cover fonts + +Place a Chinese-capable TTF/OTF here as `Cover.ttf` (or set `LARABLOG_COVER_FONT`). + +Used by template cover generation (`ArticleCoverGenerator`). Without a font, the gradient card is still written but title text may be omitted / system-font dependent. diff --git a/resources/views/filament/partials/plugin-docs.blade.php b/resources/views/filament/partials/plugin-docs.blade.php new file mode 100644 index 0000000..72e9cc0 --- /dev/null +++ b/resources/views/filament/partials/plugin-docs.blade.php @@ -0,0 +1,7 @@ +
+ @if (($html ?? '') === '') +

{{ __('admin.messages.plugin_docs_missing') }}

+ @else + {!! $html !!} + @endif +
diff --git a/tests/Feature/AiPipelineTest.php b/tests/Feature/AiPipelineTest.php index 025e97a..1195cfe 100644 --- a/tests/Feature/AiPipelineTest.php +++ b/tests/Feature/AiPipelineTest.php @@ -2,12 +2,14 @@ namespace Tests\Feature; +use App\Contracts\LlmProvider; use App\Domain\Ai\Jobs\ModerateCommentJob; use App\Domain\Ai\Jobs\OptimizeArticleContentJob; use App\Models\Article; use App\Models\Category; use App\Models\Comment; use App\Models\User; +use App\Settings\AiSettings; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Queue; use Tests\TestCase; @@ -31,13 +33,15 @@ class AiPipelineTest extends TestCase ]); (new OptimizeArticleContentJob($article->id))->handle( - app(\App\Contracts\LlmProvider::class), - app(\App\Settings\AiSettings::class), + app(LlmProvider::class), + app(AiSettings::class), ); $article->refresh(); $this->assertNotEmpty($article->ai_summary); $this->assertIsArray($article->ai_suggestions); + $this->assertNotEmpty($article->ai_polished_content); + $this->assertStringContainsString('Hello world body', (string) $article->ai_polished_content); } public function test_moderate_job_approves_non_spam(): void @@ -63,8 +67,8 @@ class AiPipelineTest extends TestCase ]); (new ModerateCommentJob($comment->id))->handle( - app(\App\Contracts\LlmProvider::class), - app(\App\Settings\AiSettings::class), + app(LlmProvider::class), + app(AiSettings::class), ); $this->assertSame(Comment::STATUS_APPROVED, $comment->fresh()->moderation_status); diff --git a/tests/Feature/ArticleCoverTest.php b/tests/Feature/ArticleCoverTest.php new file mode 100644 index 0000000..8f82251 --- /dev/null +++ b/tests/Feature/ArticleCoverTest.php @@ -0,0 +1,126 @@ +attachmentsRoot = storage_path('framework/testing/attachments-'.uniqid()); + File::ensureDirectoryExists($this->attachmentsRoot); + + config([ + 'filesystems.disks.attachments' => [ + 'driver' => 'local', + 'root' => $this->attachmentsRoot, + 'url' => rtrim((string) config('app.url'), '/').'/attachments-local', + 'visibility' => 'public', + 'throw' => true, + ], + 'larablog.attachments_disk' => 'attachments', + ]); + } + + protected function tearDown(): void + { + if (isset($this->attachmentsRoot) && is_dir($this->attachmentsRoot)) { + File::deleteDirectory($this->attachmentsRoot); + } + + parent::tearDown(); + } + + public function test_auto_cover_from_markdown_remote_image(): void + { + $article = $this->makeArticle([ + 'content' => "Hello\n\n![cover](https://cdn.example.com/hero.jpg)\n\nMore text", + ]); + + app(ArticleCoverService::class)->apply($article, 'auto'); + $article->refresh(); + + $this->assertTrue($article->hasCover()); + $this->assertSame(ArticleCoverService::SOURCE_CONTENT_IMAGE, $article->cover_source); + $this->assertSame('url', $article->cover_disk); + $this->assertSame('https://cdn.example.com/hero.jpg', $article->coverUrl()); + } + + public function test_auto_cover_from_attachment_when_no_body_image(): void + { + $article = $this->makeArticle(['content' => 'No images here']); + + Attachment::query()->create([ + 'article_id' => $article->id, + 'disk' => 'attachments', + 'path' => 'covers/demo.png', + 'filename' => 'demo.png', + 'mime' => 'image/png', + 'size' => 12, + 'visibility' => Attachment::VISIBILITY_PUBLIC, + ]); + + (new GenerateArticleCoverJob($article->id, 'auto')) + ->handle(app(ArticleCoverService::class)); + + $article->refresh(); + $this->assertTrue($article->hasCover()); + $this->assertSame(ArticleCoverService::SOURCE_ATTACHMENT, $article->cover_source); + $this->assertSame('covers/demo.png', $article->cover_path); + } + + public function test_generate_strategy_renders_template_cover(): void + { + $article = $this->makeArticle([ + 'title' => 'Template Cover Smoke', + 'description' => 'A short blurb for the generated cover card.', + ]); + + (new GenerateArticleCoverJob($article->id, 'generate')) + ->handle(app(ArticleCoverService::class)); + + $article->refresh(); + $this->assertTrue($article->hasCover()); + $this->assertSame(ArticleCoverService::SOURCE_GENERATED, $article->cover_source); + $this->assertTrue(Storage::disk('attachments')->exists((string) $article->cover_path)); + $this->assertGreaterThan(1000, Storage::disk('attachments')->size((string) $article->cover_path)); + $this->assertSame(1, Attachment::query()->where('article_id', $article->id)->count()); + } + + /** + * @param array $overrides + */ + protected function makeArticle(array $overrides = []): Article + { + $user = User::factory()->create(); + $category = Category::query()->create(['name' => 'Cover', 'display_order' => 0]); + + return Article::query()->create(array_merge([ + 'category_id' => $category->id, + 'user_id' => $user->id, + 'title' => 'Cover post', + 'content' => 'Body', + 'content_format' => 'markdown', + 'published_at' => now()->subMinute(), + 'visible' => true, + ], $overrides)); + } +} diff --git a/tests/Feature/BlogFrontendTest.php b/tests/Feature/BlogFrontendTest.php index 186fc06..0cf3a97 100644 --- a/tests/Feature/BlogFrontendTest.php +++ b/tests/Feature/BlogFrontendTest.php @@ -2,8 +2,10 @@ namespace Tests\Feature; +use App\Filament\Resources\Articles\ArticleResource; use App\Models\Article; use App\Models\Category; +use App\Models\Comment; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -37,4 +39,94 @@ class BlogFrontendTest extends TestCase $this->get('/rss.xml')->assertOk(); $this->get('/sitemap.xml')->assertOk(); } + + public function test_category_page_exposes_intro_and_seo(): void + { + $user = User::factory()->create(); + $category = Category::query()->create([ + 'name' => '随笔', + 'display_order' => 0, + 'description' => '随笔分类摘要', + 'intro' => '这是分类介绍,给列表页和爬虫看。', + 'keywords' => '随笔,笔记', + ]); + Article::query()->create([ + 'category_id' => $category->id, + 'user_id' => $user->id, + 'title' => '分类里的文章', + 'content' => 'body', + 'content_format' => 'html', + 'published_at' => now()->subHour(), + 'visible' => true, + ]); + + $this->get('/category-'.$category->id.'.shtml') + ->assertOk() + ->assertSee('随笔', false) + ->assertSee('这是分类介绍,给列表页和爬虫看。', false) + ->assertSee('分类里的文章', false) + ->assertSee('', false) + ->assertSee('', false) + ->assertSee('CollectionPage', false) + ->assertSee('/category-'.$category->id.'.shtml', false); + + $this->get('/sitemap.xml')->assertOk()->assertSee('/category-'.$category->id.'.shtml', false); + $this->get('/llms.txt')->assertOk()->assertSee('随笔')->assertSee('随笔分类摘要'); + } + + public function test_comment_author_website_uses_noreferrer(): void + { + $user = User::factory()->create(); + $category = Category::query()->create(['name' => '随笔', 'display_order' => 0]); + $article = Article::query()->create([ + 'category_id' => $category->id, + 'user_id' => $user->id, + 'title' => 'Hello', + 'content' => 'Hi', + 'content_format' => 'html', + 'description' => '文章摘要给评论后台看', + 'published_at' => now()->subHour(), + 'visible' => true, + 'comments_count' => 1, + ]); + Comment::query()->create([ + 'article_id' => $article->id, + 'author' => '访客甲', + 'url' => 'https://example.com/me', + 'content' => '一条评论', + 'moderation_status' => Comment::STATUS_APPROVED, + 'published_at' => now()->subMinute(), + ]); + + $this->get('/show-'.$article->id.'.shtml') + ->assertOk() + ->assertSee('访客甲', false) + ->assertSee('href="https://example.com/me"', false) + ->assertSee('rel="nofollow noopener noreferrer"', false) + ->assertDontSee('javascript:', false); + + $this->get('/comments.shtml') + ->assertOk() + ->assertSee('rel="nofollow noopener noreferrer"', false); + + $unsafe = new Comment(['url' => 'javascript:alert(1)']); + $this->assertNull($unsafe->websiteHref()); + } + + public function test_category_articles_count_url_targets_article_filter(): void + { + $category = Category::query()->create(['name' => '随笔', 'display_order' => 0]); + $url = ArticleResource::getUrl('index', [ + 'filters' => [ + 'category_id' => ['value' => $category->id], + ], + ]); + + $this->assertStringContainsString((string) $category->id, $url); + $decoded = urldecode($url); + $this->assertTrue( + str_contains($decoded, 'filters[category_id]') || str_contains($url, 'filters'), + $url + ); + } } diff --git a/tests/Feature/MembershipCommerceTest.php b/tests/Feature/MembershipCommerceTest.php new file mode 100644 index 0000000..55bab6b --- /dev/null +++ b/tests/Feature/MembershipCommerceTest.php @@ -0,0 +1,243 @@ +artisan('migrate', ['--path' => 'plugins/larablog/payment/database/migrations']); + $this->artisan('migrate', ['--path' => 'plugins/larablog/paid-content/database/migrations']); + $this->artisan('migrate', ['--path' => 'plugins/larablog/membership/database/migrations']); + + app()->register(PaymentProvider::class); + app()->register(PaidContentProvider::class); + app()->register(MembershipProvider::class); + + (new MembershipPlanSeeder)->run(); + } + + public function test_membership_requires_payment_plugin(): void + { + $manager = app(PluginManager::class); + $manager->syncDiscoveredPlugins(); + + Plugin::query()->where('name', 'larablog/payment')->update(['enabled' => false]); + Plugin::query()->where('name', 'larablog/membership')->update(['enabled' => false]); + + $this->expectException(RuntimeException::class); + $manager->enable('larablog/membership'); + } + + public function test_subscribe_monthly_sets_expires_at_and_unlocks_article(): void + { + $manager = app(PluginManager::class); + $manager->syncDiscoveredPlugins(); + $manager->enable('larablog/payment'); + $manager->enable('larablog/membership'); + + $user = User::factory()->create(); + [$article] = $this->makeArticle([ + 'content' => str_repeat('secret ', 40).'MEMBER_FULL_TOKEN', + ]); + + ArticleMembership::query()->create([ + 'article_id' => $article->id, + 'enabled' => true, + 'required_plan_id' => null, + ]); + + $this->get('/show-'.$article->id.'.shtml') + ->assertOk() + ->assertDontSee('MEMBER_FULL_TOKEN'); + + $plan = MembershipPlan::query()->where('slug', 'monthly')->firstOrFail(); + $orders = app(OrderService::class); + $order = $orders->createOrder($user, ProductType::MEMBERSHIP, (int) $plan->id, $plan->name, (string) $plan->price); + $orders->markPaid($order); + + $status = app(MembershipService::class)->statusFor($user); + $this->assertTrue($status['active']); + $this->assertSame('monthly', $status['plan_slug']); + $this->assertNotNull($status['expires_at']); + + $this->actingAs($user) + ->get('/show-'.$article->id.'.shtml') + ->assertOk() + ->assertSee('MEMBER_FULL_TOKEN'); + + $this->actingAs($user) + ->getJson('/plugins/membership/status') + ->assertOk() + ->assertJsonPath('active', true) + ->assertJsonPath('plan_slug', 'monthly'); + } + + public function test_expired_membership_can_renew(): void + { + $user = User::factory()->create(); + $plan = MembershipPlan::query()->where('slug', 'monthly')->firstOrFail(); + $orders = app(OrderService::class); + + $first = $orders->createOrder($user, ProductType::MEMBERSHIP, (int) $plan->id, $plan->name, (string) $plan->price); + $orders->markPaid($first); + + Entitlement::query() + ->where('user_id', $user->id) + ->where('product_type', ProductType::MEMBERSHIP) + ->where('product_id', $plan->id) + ->update(['expires_at' => now()->subDay()]); + + $this->assertFalse($orders->hasEntitlement((int) $user->id, ProductType::MEMBERSHIP, (int) $plan->id)); + + $second = $orders->createOrder($user, ProductType::MEMBERSHIP, (int) $plan->id, $plan->name, (string) $plan->price); + $orders->markPaid($second); + + $this->assertTrue($orders->hasEntitlement((int) $user->id, ProductType::MEMBERSHIP, (int) $plan->id)); + $entitlement = Entitlement::query() + ->where('user_id', $user->id) + ->where('product_type', ProductType::MEMBERSHIP) + ->where('product_id', $plan->id) + ->firstOrFail(); + $this->assertTrue($entitlement->expires_at?->isFuture()); + } + + public function test_required_plan_gate_rejects_other_plan(): void + { + $manager = app(PluginManager::class); + $manager->syncDiscoveredPlugins(); + $manager->enable('larablog/payment'); + $manager->enable('larablog/membership'); + + $user = User::factory()->create(); + [$article] = $this->makeArticle(['content' => 'NEED_LIFETIME_TOKEN '.str_repeat('x ', 30)]); + $monthly = MembershipPlan::query()->where('slug', 'monthly')->firstOrFail(); + $lifetime = MembershipPlan::query()->where('slug', 'lifetime')->firstOrFail(); + + ArticleMembership::query()->create([ + 'article_id' => $article->id, + 'enabled' => true, + 'required_plan_id' => $lifetime->id, + ]); + + $orders = app(OrderService::class); + $order = $orders->createOrder($user, ProductType::MEMBERSHIP, (int) $monthly->id, $monthly->name, (string) $monthly->price); + $orders->markPaid($order); + + $decision = app(ArticleAccess::class)->resolve($article, $user); + $this->assertSame(AccessDecision::NEED_PURCHASE, $decision->status); + + $lifeOrder = $orders->createOrder($user, ProductType::MEMBERSHIP, (int) $lifetime->id, $lifetime->name, (string) $lifetime->price); + $orders->markPaid($lifeOrder); + + $this->assertTrue(app(ArticleAccess::class)->resolve($article, $user)->isAllow()); + } + + public function test_forge_membership_amount_query_is_rejected(): void + { + $user = User::factory()->create(); + $plan = MembershipPlan::query()->where('slug', 'monthly')->firstOrFail(); + + $this->actingAs($user) + ->get('/plugins/payment/checkout?'.http_build_query([ + 'product_type' => ProductType::MEMBERSHIP, + 'product_id' => $plan->id, + 'amount' => '0.01', + 'title' => 'hack', + ])) + ->assertRedirect(); + + // Server-side price must be used; after redirect order amount is plan price. + $this->assertDatabaseHas('orders', [ + 'user_id' => $user->id, + 'amount' => '9.90', + ]); + + $this->actingAs($user) + ->get('/plugins/payment/checkout?'.http_build_query([ + 'product_type' => 'theme', + 'product_id' => 1, + 'amount' => '0.01', + 'title' => 'hack', + ])) + ->assertStatus(422); + } + + public function test_mutex_validate_hook_rejects_paid_and_membership(): void + { + $data = [ + 'read_password' => null, + 'paid_content' => ['enabled' => true, 'price' => 1], + 'membership' => ['enabled' => true], + ]; + + $this->expectException(ValidationException::class); + Hook::filter('filament.article.validate_access_restrictions', $data, null); + } + + public function test_cannot_delete_plan_with_entitlement(): void + { + $user = User::factory()->create(); + $plan = MembershipPlan::query()->where('slug', 'monthly')->firstOrFail(); + $orders = app(OrderService::class); + $order = $orders->createOrder($user, ProductType::MEMBERSHIP, (int) $plan->id, $plan->name, (string) $plan->price); + $orders->markPaid($order); + + $this->assertTrue($plan->fresh()->hasEntitlements()); + } + + /** + * @param array $overrides + * @return array{0: Article, 1: User, 2: Category} + */ + protected function makeArticle(array $overrides = []): array + { + $user = isset($overrides['user_id']) + ? User::query()->findOrFail($overrides['user_id']) + : User::factory()->create(); + $category = Category::query()->create(['name' => 'Mem', 'display_order' => 0]); + + $article = Article::query()->create(array_merge([ + 'category_id' => $category->id, + 'user_id' => $user->id, + 'title' => 'Members post', + 'content' => 'Hello', + 'content_format' => 'markdown', + 'published_at' => now()->subMinute(), + 'visible' => true, + ], $overrides)); + + return [$article, $user, $category]; + } +} diff --git a/tests/Feature/PaidContentCommerceTest.php b/tests/Feature/PaidContentCommerceTest.php index 85bd297..1ab6c97 100644 --- a/tests/Feature/PaidContentCommerceTest.php +++ b/tests/Feature/PaidContentCommerceTest.php @@ -77,7 +77,7 @@ class PaidContentCommerceTest extends TestCase $author = User::factory()->create(); [$article] = $this->makeArticle([ 'user_id' => $author->id, - 'content' => "## Intro\n\n".str_repeat('teaser ', 10)."UNIQUE_FULL_TOKEN ".str_repeat('tail ', 10), + 'content' => "## Intro\n\n".str_repeat('teaser ', 10).'UNIQUE_FULL_TOKEN '.str_repeat('tail ', 10), ]); ArticleProduct::query()->create([ @@ -353,6 +353,20 @@ class PaidContentCommerceTest extends TestCase $this->assertSame(AccessDecision::NEED_PURCHASE, $access->resolve($article, null)->status); } + public function test_article_table_hooks_expose_price_column_and_filter(): void + { + $columnNames = collect(Hook::collect('filament.article.table.columns')) + ->map(fn ($column) => $column->getName()) + ->all(); + $this->assertContains('paid_content_enabled', $columnNames); + $this->assertContains('paid_content_price', $columnNames); + + $filterNames = collect(Hook::collect('filament.article.table.filters')) + ->map(fn ($filter) => $filter->getName()) + ->all(); + $this->assertContains('paid_enabled', $filterNames); + } + /** * @param array $overrides * @return array{0: Article, 1: User, 2: Category} diff --git a/tests/Feature/PluginDocsTest.php b/tests/Feature/PluginDocsTest.php new file mode 100644 index 0000000..55d9e05 --- /dev/null +++ b/tests/Feature/PluginDocsTest.php @@ -0,0 +1,99 @@ +setLocale('zh_CN'); + $manager = app(PluginManager::class); + + $markdown = $manager->readDocs('larablog/paid-content'); + $html = $manager->readDocsHtml('larablog/paid-content'); + + $this->assertNotNull($markdown); + $this->assertNotNull($html); + $this->assertStringContainsString('付费内容', $markdown); + $this->assertStringContainsString('assertStringContainsString('付费内容', $html); + $this->assertStringContainsString('', $html); + $this->assertStringNotContainsString('# Paid Content', (string) $markdown); + $this->assertStringNotContainsString('# 付费内容', $html); + } + + public function test_docs_prefer_locale_file_then_fallback_to_readme(): void + { + $root = storage_path('framework/testing/plugins-'.uniqid()); + $pluginDir = $root.'/acme/docsdemo'; + mkdir($pluginDir, 0777, true); + file_put_contents($pluginDir.'/plugin.json', json_encode([ + 'name' => 'acme/docsdemo', + 'title' => 'Docs demo', + 'version' => '1.0.0', + 'docs' => 'README.md', + ])); + file_put_contents($pluginDir.'/README.md', "# English\n\nHello **world**."); + file_put_contents($pluginDir.'/README.zh_CN.md', "# 中文说明\n\n这是**加粗**。\n"); + + config(['larablog.plugin_path' => $root]); + $manager = app(PluginManager::class); + + try { + $zh = (string) $manager->readDocs('acme/docsdemo', 'zh_CN'); + $this->assertStringContainsString('中文说明', $zh); + + $html = (string) $manager->readDocsHtml('acme/docsdemo', 'zh_CN'); + $this->assertStringContainsString('assertStringContainsString('中文说明', $html); + $this->assertStringContainsString('', $html); + $this->assertStringNotContainsString('# 中文说明', $html); + + $en = (string) $manager->readDocs('acme/docsdemo', 'en'); + $this->assertStringContainsString('English', $en); + $this->assertStringNotContainsString('中文说明', $en); + } finally { + unlink($pluginDir.'/README.zh_CN.md'); + unlink($pluginDir.'/README.md'); + unlink($pluginDir.'/plugin.json'); + rmdir($pluginDir); + rmdir($root.'/acme'); + rmdir($root); + } + } + + public function test_unsafe_locale_does_not_escape_plugin_directory(): void + { + $root = storage_path('framework/testing/plugins-'.uniqid()); + $pluginDir = $root.'/acme/safe'; + mkdir($pluginDir, 0777, true); + file_put_contents($pluginDir.'/plugin.json', json_encode([ + 'name' => 'acme/safe', + 'title' => 'Safe', + 'version' => '1.0.0', + 'docs' => 'README.md', + ])); + file_put_contents($pluginDir.'/README.md', "# Safe\n"); + + config(['larablog.plugin_path' => $root]); + $manager = app(PluginManager::class); + + try { + $path = $manager->docsPath('acme/safe', '../..'); + $this->assertNotNull($path); + $this->assertSame(realpath($pluginDir.'/README.md'), $path); + $this->assertStringContainsString('Safe', (string) $manager->readDocs('acme/safe', '../..')); + } finally { + unlink($pluginDir.'/README.md'); + unlink($pluginDir.'/plugin.json'); + rmdir($pluginDir); + rmdir($root.'/acme'); + rmdir($root); + } + } +} diff --git a/tests/fixtures/sablog/README.md b/tests/fixtures/sablog/README.md index 680d545..6dce487 100644 --- a/tests/fixtures/sablog/README.md +++ b/tests/fixtures/sablog/README.md @@ -1,6 +1,6 @@ # sablog 最小样例包 -用于 `sablog:import` 回归测试,不依赖真实 sablog 库。 +用于 `sablog:import` 回归测试,不依赖真实 sablog 库。完整导入步骤见 [docs/ops/import.md](../../../docs/ops/import.md)。 - `schema.sql` / `seed.sql`:SQLite 源库结构与数据 - `attachments/`:本地附件目录(与 `filepath` 对齐) diff --git a/themes/default/assets/style.css b/themes/default/assets/style.css index a2c6bbc..e3408f2 100644 --- a/themes/default/assets/style.css +++ b/themes/default/assets/style.css @@ -129,6 +129,32 @@ a:hover { color: var(--teal); } } .post-list { display: grid; gap: 0; } +.post-item__cover { + display: block; + margin: 0 0 0.75rem; + border-radius: 4px; + overflow: hidden; + aspect-ratio: 16 / 9; + background: #e8eef2; +} +.post-item__cover img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.post__cover { + margin: 0 0 1.25rem; + border-radius: 4px; + overflow: hidden; + background: #e8eef2; +} +.post__cover img { + width: 100%; + max-height: 420px; + object-fit: cover; + display: block; +} .post-item { padding: 1.35rem 0; border-bottom: 1px solid var(--line); @@ -148,6 +174,24 @@ a:hover { color: var(--teal); } .meta { color: var(--muted); font-size: 0.92rem; margin: 0 0 0.55rem; } .excerpt { margin: 0; color: #243442; } +.category-hero { margin-bottom: 1.25rem; } +.category-hero__cover { + margin: 0 0 1rem; + border-radius: calc(var(--radius) - 0.35rem); + overflow: hidden; +} +.category-hero__cover img { + width: 100%; + max-height: 240px; + object-fit: cover; + display: block; +} +.category-hero__intro { + margin: 0 0 0.75rem; + color: #243442; + line-height: 1.7; +} + .page-title { font-family: var(--serif); font-size: clamp(1.8rem, 3vw, 2.4rem); @@ -292,6 +336,8 @@ a:hover { color: var(--teal); } margin-bottom: 0.35rem; } .comment__author { color: var(--ink); } +a.comment__author { text-decoration: none; } +a.comment__author:hover { color: var(--teal); } .comment__body { color: #243442; } .article-body .body { font-size: 1.05rem; } diff --git a/themes/default/views/article.blade.php b/themes/default/views/article.blade.php index 66c73d0..6615fc8 100644 --- a/themes/default/views/article.blade.php +++ b/themes/default/views/article.blade.php @@ -34,6 +34,12 @@

+ @if($article->hasCover() && ($coverUrl = $article->coverUrl())) +
+ {{ $article->title }} +
+ @endif + @if(count($toc) >= 2)