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\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()))
+
+
+
+ @endif
+
@if(count($toc) >= 2)