feat: post cover filter + admin AI cover action, restyle page-cache admin
Build and Deploy / verify (push) Successful in 10s
Build and Deploy / deploy (push) Successful in 17s

- 抽 CoverGenerator 服务(covers:generate 与后台共用),内部强制同步产出 card 转换
- 文章列表:新增封面状态图标列、「有/无封面/完全无图」筛选、行内「生成封面」AI 操作
- 页面缓存:统计改为原生 StatsOverviewWidget(缓存页数/总大小/命中率/时长),
  缓存明细表重排(表头、悬停、URL 截断、图标删除按钮、空状态)
This commit is contained in:
2026-10-14 23:48:57 +00:00
parent 58ebaf7f90
commit d041622531
6 changed files with 254 additions and 148 deletions
+7 -71
View File
@@ -4,16 +4,13 @@ declare(strict_types=1);
namespace App\Console\Commands;
use App\Blog\Services\LlmClient;
use App\Blog\Services\CoverGenerator;
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)。
* 批量为文章生成 AI 封面(默认只补完全没有任何图片的文章)。
*/
class GenerateCovers extends Command
{
@@ -27,18 +24,16 @@ class GenerateCovers extends Command
protected $description = '用 AI 为文章批量生成封面图(存入 media cover 集合)';
public function handle(LlmClient $llm): int
public function handle(CoverGenerator $covers): int
{
if (! $llm->isConfigured()) {
if (! $covers->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'));
// 站点无常驻 queue worker:批量生成期间同步产出 card 转换,避免永远卡在队列里。
config(['media-library.queue_conversions_by_default' => false]);
$model = $covers->model($this->option('model') ?: null);
$style = $this->option('style') ?: null;
$query = Post::query()->published()->orderedForList();
@@ -75,7 +70,7 @@ class GenerateCovers extends Command
foreach ($todo as $post) {
try {
$this->generateFor($post, $llm, $model);
$covers->generateFor($post, $model, $style);
$ok++;
$this->info("✔ [{$post->id}] {$post->title}");
} catch (\Throwable $e) {
@@ -89,63 +84,4 @@ class GenerateCovers extends Command
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);
}
}