feat: list mode 50/page, card mode left-image rows, AI cover generation command
Build and Deploy / verify (push) Successful in 10s
Build and Deploy / deploy (push) Successful in 18s

- blog_paginate: 纯列表模式每页 50 条(list_per_page 可调),其余模式仍走 per_page
- 图文(card)模式:卡片网格改为左图右文横排,小屏回退竖排;摘要放宽到 100 字
- 新增 covers:generate:复用 AI 设置的 LLM 配置调 /image_generation(默认 image-01),
  为无封面文章批量生成封面存入 media cover 集合;支持 --all/--limit/--post/--style/--dry-run
- AI 设置页新增图片模型、封面风格两项配置
- 修正 HomeViewModeTest:列表模式为纯标题(不再显示摘要),并断言每页 50 条与图文布局
This commit is contained in:
2026-10-13 23:58:15 +00:00
parent 16dac8b568
commit 57b6221d90
7 changed files with 202 additions and 44 deletions
+148
View File
@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Blog\Services\LlmClient;
use App\Models\Post;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* 用 AI 图像接口(OpenAI 兼容 /image_generation,如 MiniMax image-01)批量生成文章封面。
* 复用「AI 设置」里的 llm_base_url / llm_api_key,模型取 llm_image_model(默认 image-01)。
*/
class GenerateCovers extends Command
{
protected $signature = 'covers:generate
{--all : 全部重新生成(默认只处理没有任何封面图的文章)}
{--limit= : 最多处理多少篇}
{--post= : 只处理指定文章 ID,逗号分隔}
{--model= : 图片模型,覆盖 llm_image_model 设置}
{--style= : 附加到提示词的风格描述}
{--dry-run : 只列出将处理的文章,不调用接口}';
protected $description = '用 AI 为文章批量生成封面图(存入 media cover 集合)';
public function handle(LlmClient $llm): int
{
if (! $llm->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);
}
}
+4
View File
@@ -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();
@@ -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([
+15 -20
View File
@@ -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%;
}
}
+1 -1
View File
@@ -18,7 +18,7 @@
@if($label = $post->stickyLabel())<span class="badge">{{ $label }}</span>@endif
<a wire:navigate href="{{ route('posts.show', $post->id) }}">{{ $post->title }}</a>
</h2>
<div class="post-excerpt">{{ \Illuminate\Support\Str::limit($post->excerpt_or_fallback, 80) }}</div>
<div class="post-excerpt">{{ \Illuminate\Support\Str::limit($post->excerpt_or_fallback, 100) }}</div>
<div class="post-meta">
<time datetime="{{ $post->published_at->toIso8601String() }}">{{ $post->published_at->format('Y-m-d') }}</time>
@if($post->category)
+14 -3
View File
@@ -35,12 +35,13 @@ class HomeViewModeTest extends TestCase
]);
}
public function test_list_mode_shows_excerpt_not_full_body(): void
public function test_list_mode_is_title_only(): void
{
$this->get('/?view=list')
->assertOk()
->assertSee('浏览模式', false)
->assertSee('列表摘要文字', false)
->assertSee('浏览模式测试文', false)
->assertDontSee('列表摘要文字', false)
->assertDontSee('FULL_BODY_ONLY_TOKEN', false);
}
@@ -70,9 +71,19 @@ class HomeViewModeTest extends TestCase
]);
}
// 列表模式每页 50 条:13 篇文章应在同一页
$this->get('/')
->assertOk()
->assertSee('pagination-info', false)
->assertSee('1 /', false);
->assertSee('1 / 1', false);
}
public function test_card_mode_uses_left_image_layout(): void
{
$this->get('/?view=card')
->assertOk()
->assertSee('post-list-card', false)
->assertSee('post-card-card', false)
->assertSee('post-card-body', false);
}
}
+15 -20
View File
@@ -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%;
}
}