diff --git a/app/Console/Commands/GenerateCovers.php b/app/Console/Commands/GenerateCovers.php new file mode 100644 index 0000000..ef6bacb --- /dev/null +++ b/app/Console/Commands/GenerateCovers.php @@ -0,0 +1,148 @@ +isConfigured()) { + $this->error('LLM 未配置:请先在后台「AI 设置」或 .env 配置 LLM_BASE_URL / LLM_API_KEY'); + + return self::FAILURE; + } + + $model = (string) ($this->option('model') ?: blog_setting('llm_image_model', 'image-01')); + + $query = Post::query()->published()->orderedForList(); + + if ($ids = trim((string) $this->option('post'))) { + $query->whereIn('id', array_filter(array_map('intval', explode(',', $ids)))); + } + + $todo = $query->get() + ->filter(fn (Post $post) => $this->option('all') || $post->coverOrFirstImageUrl() === null) + ->values(); + + if ($limit = (int) $this->option('limit')) { + $todo = $todo->take($limit)->values(); + } + + if ($todo->isEmpty()) { + $this->info('没有需要生成封面的文章。'); + + return self::SUCCESS; + } + + $this->info("待处理 {$todo->count()} 篇,模型 {$model}"); + + if ($this->option('dry-run')) { + foreach ($todo as $post) { + $this->line("[{$post->id}] {$post->title}"); + } + + return self::SUCCESS; + } + + $ok = 0; + $fail = 0; + + foreach ($todo as $post) { + try { + $this->generateFor($post, $llm, $model); + $ok++; + $this->info("✔ [{$post->id}] {$post->title}"); + } catch (\Throwable $e) { + $fail++; + $this->warn("✘ [{$post->id}] {$post->title}:{$e->getMessage()}"); + Log::warning('covers:generate 失败', ['post_id' => $post->id, 'error' => $e->getMessage()]); + } + } + + $this->info("完成:成功 {$ok},失败 {$fail}"); + + return $ok === 0 && $fail > 0 ? self::FAILURE : self::SUCCESS; + } + + private function generateFor(Post $post, LlmClient $llm, string $model): void + { + $response = Http::timeout(120) + ->withToken($llm->apiKey()) + ->post($llm->baseUrl().'/image_generation', [ + 'model' => $model, + 'prompt' => $this->buildPrompt($post), + 'aspect_ratio' => '16:9', + 'response_format' => 'url', + 'n' => 1, + 'prompt_optimizer' => true, + ]); + + if ($response->failed()) { + throw new \RuntimeException('HTTP '.$response->status().':'.Str::limit($response->body(), 200)); + } + + $url = $response->json('data.image_urls.0'); + if (! is_string($url) || $url === '') { + $reason = $response->json('base_resp.status_msg') ?: $response->body(); + + throw new \RuntimeException('接口未返回图片:'.Str::limit((string) $reason, 200)); + } + + $image = Http::timeout(60)->get($url)->throw(); + $ext = match (strtolower(explode(';', (string) $image->header('Content-Type'))[0])) { + 'image/png' => 'png', + 'image/webp' => 'webp', + default => 'jpg', + }; + + $tmp = tempnam(sys_get_temp_dir(), 'cover_'); + file_put_contents($tmp, $image->body()); + + try { + $post->addMedia($tmp) + ->usingFileName('cover-'.$post->id.'.'.$ext) + ->toMediaCollection('cover'); + } finally { + @unlink($tmp); + } + } + + private function buildPrompt(Post $post): string + { + $style = trim((string) ($this->option('style') + ?: blog_setting('cover_prompt_style', '扁平插画风格,色调柔和,构图简洁'))); + $excerpt = Str::limit(trim(strip_tags((string) $post->excerpt)), 60, ''); + + $parts = ["为博客文章《{$post->title}》生成一张封面配图"]; + if ($excerpt !== '') { + $parts[] = "内容大意:{$excerpt}"; + } + $parts[] = $style; + $parts[] = '画面中不要出现任何文字'; + + return implode('。', $parts); + } +} diff --git a/app/Support/helpers.php b/app/Support/helpers.php index d785d29..d831bc9 100644 --- a/app/Support/helpers.php +++ b/app/Support/helpers.php @@ -39,6 +39,10 @@ if (! function_exists('blog_paginate')) { */ function blog_paginate(\Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Eloquent\Relations\Relation $query, ?int $perPage = null): \Illuminate\Contracts\Pagination\LengthAwarePaginator { + // 纯列表模式每页固定 50 条(标题列表,信息密度高);其余模式走设置。 + $perPage ??= \App\Blog\Support\HomeViewMode::current() === \App\Blog\Support\HomeViewMode::LIST + ? (int) blog_setting('list_per_page', 50) + : null; $perPage = max(1, (int) ($perPage ?? blog_setting('per_page', config('blog.per_page')))); return $query->paginate($perPage)->onEachSide(1)->withQueryString(); diff --git a/plugins/neatstudio.ai-moderation/src/Filament/Pages/AiSettings.php b/plugins/neatstudio.ai-moderation/src/Filament/Pages/AiSettings.php index 9b763ad..90b1240 100644 --- a/plugins/neatstudio.ai-moderation/src/Filament/Pages/AiSettings.php +++ b/plugins/neatstudio.ai-moderation/src/Filament/Pages/AiSettings.php @@ -42,6 +42,8 @@ class AiSettings extends Page 'llm_base_url' => Setting::get('llm_base_url', env('LLM_BASE_URL')), 'llm_api_key' => Setting::get('llm_api_key', env('LLM_API_KEY')), 'llm_model' => Setting::get('llm_model', env('LLM_MODEL', 'gpt-4o-mini')), + 'llm_image_model' => Setting::get('llm_image_model', 'image-01'), + 'cover_prompt_style' => Setting::get('cover_prompt_style', '扁平插画风格,色调柔和,构图简洁'), 'ai_moderation_enabled' => (int) Setting::get('ai_moderation_enabled', 1) === 1, 'ai_moderation_system_prompt' => Setting::get('ai_moderation_system_prompt', '你是博客评论审核员。判断评论是否包含:广告/垃圾、人身攻击、违法内容、无关灌水。只回复 JSON:{"verdict":"approved|rejected|spam","reason":"简短理由"}'), ]); @@ -57,6 +59,9 @@ class AiSettings extends Page TextInput::make('llm_base_url')->label('Base URL')->placeholder('https://api.openai.com/v1')->required(), TextInput::make('llm_api_key')->label('API Key')->password()->required(), TextInput::make('llm_model')->label('模型')->default('gpt-4o-mini')->required(), + TextInput::make('llm_image_model')->label('图片模型')->default('image-01') + ->helperText('供 php artisan covers:generate 批量生成封面使用(接口:{Base URL}/image_generation)'), + TextInput::make('cover_prompt_style')->label('封面风格')->default('扁平插画风格,色调柔和,构图简洁'), ]), \Filament\Schemas\Components\Section::make('评论审核') ->schema([ diff --git a/public/themes/sablog/style.css b/public/themes/sablog/style.css index cd43a49..2bd306c 100644 --- a/public/themes/sablog/style.css +++ b/public/themes/sablog/style.css @@ -122,24 +122,19 @@ a:hover { margin-left: 10px; } /* 图文模式:卡片网格 */ -.post-list-card { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 16px; - margin-bottom: 16px; -} .post-list-card .post-card { - margin-bottom: 0; - padding: 0; - overflow: hidden; display: flex; - flex-direction: column; + gap: 16px; + align-items: stretch; } .post-list-card .post-cover { - display: block; + flex: 0 0 200px; + width: 200px; aspect-ratio: 16 / 10; + margin: 0; background: #eef2f5; overflow: hidden; + border-radius: 4px; } .post-list-card .post-cover img { width: 100%; @@ -159,11 +154,12 @@ a:hover { background: linear-gradient(135deg, #eef2f5 0%, #dde7ee 100%); } .post-list-card .post-card-body { - padding: 12px 14px; flex: 1; + min-width: 0; + padding: 2px 0; } .post-list-card .post-title { - font-size: 15px; + font-size: 16px; margin-bottom: 6px; } .post-list-card .post-excerpt { @@ -175,14 +171,13 @@ a:hover { -webkit-box-orient: vertical; overflow: hidden; } -@media (max-width: 1024px) { - .post-list-card { - grid-template-columns: repeat(2, 1fr); - } -} @media (max-width: 640px) { - .post-list-card { - grid-template-columns: 1fr; + .post-list-card .post-card { + flex-direction: column; + } + .post-list-card .post-cover { + flex: none; + width: 100%; } } diff --git a/resources/views/partials/post-card.blade.php b/resources/views/partials/post-card.blade.php index adf55fe..4cd2dfe 100644 --- a/resources/views/partials/post-card.blade.php +++ b/resources/views/partials/post-card.blade.php @@ -18,7 +18,7 @@ @if($label = $post->stickyLabel()){{ $label }}@endif {{ $post->title }} -