feat: list mode 50/page, card mode left-image rows, AI cover generation command
- 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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user