feat: topbar legacy-sync button, card view mode with cover fallback, year/month/day archives, richer page cache admin, stylevar repeater editor
Build and Deploy / verify (push) Successful in 32s
Build and Deploy / deploy (push) Successful in 17s

- topbar: Livewire LegacySyncButton next to 写文章 (moved from user menu)
- view modes: add 图文(card) grid with cover fallback chain (cover → attachment → first content image → placeholder); list mode now title-only compact
- archives: year/month/day index grid
- page cache admin: size + expiry columns, total size, cache policy note
- theme inject: stylevars switch from KeyValue to Repeater with multi-line HTML editing
- import: html_entity_decode for category/post-title/link/keywords/comment author
This commit is contained in:
2026-10-13 09:01:46 +00:00
parent 2d8752f863
commit 16dac8b568
24 changed files with 608 additions and 136 deletions
+8 -2
View File
@@ -14,8 +14,14 @@ class ArchiveController extends Controller
{
$posts = Post::published()->orderByDesc('published_at')->get(['id', 'title', 'slug', 'published_at']);
$archives = $posts->groupBy(fn (Post $post) => $post->published_at->format('Y'))
->map(fn ($yearPosts) => $yearPosts->groupBy(fn (Post $post) => $post->published_at->format('m')));
// 年 → 月(1-12) → 日 → 文章,用于按年/月/日索引
$archives = [];
foreach ($posts as $post) {
$year = $post->published_at->format('Y');
$month = (int) $post->published_at->format('m');
$day = (int) $post->published_at->format('d');
$archives[$year][$month][$day][] = $post;
}
return theme_view('archives', compact('archives'));
}
+4 -1
View File
@@ -15,6 +15,8 @@ class HomeViewMode
public const FULL = 'full';
public const CARD = 'card';
public const COOKIE = 'll_home_view';
/**
@@ -25,12 +27,13 @@ class HomeViewMode
return [
self::LIST => '列表',
self::FULL => '标准',
self::CARD => '图文',
];
}
public static function isValid(mixed $value): bool
{
return is_string($value) && in_array($value, [self::LIST, self::FULL], true);
return is_string($value) && in_array($value, [self::LIST, self::FULL, self::CARD], true);
}
public static function resolve(?Request $request = null): string
+9 -4
View File
@@ -41,11 +41,12 @@ class PageCacheMiddleware
$response = $next($request);
if ($response->getStatusCode() === 200) {
$body = $response->getContent();
Cache::put($key, [
'body' => $response->getContent(),
'body' => $body,
'type' => $response->headers->get('Content-Type') ?: 'text/html; charset=utf-8',
], $ttl);
self::rememberKey($key, $request->fullUrl());
self::rememberKey($key, $request->fullUrl(), strlen($body), $ttl);
}
return $response;
@@ -89,7 +90,7 @@ class PageCacheMiddleware
}
/**
* @return list<array{key: string, url: string, cached_at: string}>
* @return list<array{key: string, url: string, cached_at: string, size: int, expires_at: string}>
*/
public static function entries(): array
{
@@ -100,6 +101,8 @@ class PageCacheMiddleware
'key' => (string) $key,
'url' => is_array($meta) ? (string) ($meta['url'] ?? $key) : (string) $key,
'cached_at' => is_array($meta) ? (string) ($meta['cached_at'] ?? '') : '',
'size' => is_array($meta) ? (int) ($meta['size'] ?? 0) : 0,
'expires_at' => is_array($meta) ? (string) ($meta['expires_at'] ?? '') : '',
];
}
@@ -122,7 +125,7 @@ class PageCacheMiddleware
Cache::put($key, (int) Cache::get($key, 0) + 1, now()->addDay());
}
private static function rememberKey(string $key, ?string $url = null): void
private static function rememberKey(string $key, ?string $url = null, int $size = 0, int $ttl = 0): void
{
$keys = (array) Cache::get(self::TAG.'_keys', []);
$previous = $keys[$key] ?? [];
@@ -130,6 +133,8 @@ class PageCacheMiddleware
$keys[$key] = [
'url' => $url ?: (is_array($previous) ? (string) ($previous['url'] ?? '') : ''),
'cached_at' => now()->toDateTimeString(),
'size' => $size,
'expires_at' => $ttl > 0 ? now()->addSeconds($ttl)->toDateTimeString() : '',
];
Cache::put(self::TAG.'_keys', $keys, now()->addDay());
+5 -3
View File
@@ -215,6 +215,7 @@ class SablogImport extends Command
{
$count = 0;
foreach ($this->rows('categories') as $row) {
$row['name'] = html_entity_decode((string) $row['name'], ENT_QUOTES | ENT_HTML5, 'UTF-8');
$slug = SlugGenerator::make($row['name'], 'categories', 'slug', ignoreId: (int) $row['cid'], fallback: 'category-'.$row['cid']);
DB::table('categories')->updateOrInsert(['id' => (int) $row['cid']], [
'name' => $row['name'],
@@ -291,7 +292,7 @@ class SablogImport extends Command
DB::table('posts')->updateOrInsert(['id' => (int) $row['articleid']], [
'category_id' => $categoryId,
'user_id' => (int) $row['uid'] ?: null,
'title' => $row['title'],
'title' => html_entity_decode((string) $row['title'], ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'slug' => $slug,
'excerpt' => $row['description'] ?: null,
'content' => $row['content'],
@@ -323,6 +324,7 @@ class SablogImport extends Command
// 而不是 sablog_tags.aids(那个映射是错的/过时的)
foreach ($this->rows('articles') as $row) {
$keywords = trim((string) ($row['keywords'] ?? ''));
$keywords = html_entity_decode($keywords, ENT_QUOTES | ENT_HTML5, 'UTF-8');
if ($keywords === '') {
continue;
}
@@ -371,7 +373,7 @@ class SablogImport extends Command
foreach ($this->rows('comments') as $row) {
DB::table('comments')->updateOrInsert(['id' => (int) $row['commentid']], [
'post_id' => (int) $row['articleid'],
'author_name' => $row['author'] ?: '匿名',
'author_name' => html_entity_decode((string) ($row['author'] ?: '匿名'), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'author_url' => $row['url'] ?: null,
'content' => $row['content'],
'ip' => $row['ipaddress'] ?: null,
@@ -389,7 +391,7 @@ class SablogImport extends Command
$count = 0;
foreach ($this->rows('links') as $row) {
DB::table('links')->updateOrInsert(['id' => (int) $row['linkid']], [
'name' => $row['name'],
'name' => html_entity_decode((string) $row['name'], ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'url' => $row['url'],
'note' => $row['note'] ?: null,
'display_order' => (int) $row['displayorder'],
+32 -6
View File
@@ -8,7 +8,6 @@ use App\Blog\Support\ThemeInjector;
use App\Blog\Support\ThemeManager;
use App\Models\Setting;
use Filament\Actions\Action;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
@@ -38,9 +37,15 @@ class ThemeInject extends Page
public function mount(): void
{
$vars = app(ThemeManager::class)->variables();
$varRows = [];
foreach ($vars as $key => $value) {
$varRows[] = ['key' => (string) $key, 'value' => (string) $value];
}
$this->form->fill([
'inject' => $this->normalizeBlocks(Setting::get('inject.blocks', [])),
'theme_vars' => app(ThemeManager::class)->variables(),
'theme_vars' => $varRows,
]);
}
@@ -80,11 +85,23 @@ class ThemeInject extends Page
$tabs[] = Tab::make('主题变量(stylevar)')
->schema([
KeyValue::make('theme_vars')
Repeater::make('theme_vars')
->label('当前主题变量:视图用 {{ theme(\'var\', \'key\') }} 引用')
->keyLabel('变量名')
->valueLabel('内容(HTML)')
->schema([
TextInput::make('key')
->label('变量名')
->required(),
Textarea::make('value')
->label('内容(HTML)')
->rows(4),
])
->columns(1)
->defaultItems(0)
->collapsible()
->itemLabel(fn (array $state): ?string => $state['key'] ?? null)
->reorderable()
->reorderableWithButtons()
->addActionLabel('添加变量')
->columnSpanFull(),
]);
@@ -118,7 +135,16 @@ class ThemeInject extends Page
}
Setting::set('inject.blocks', $blocks);
Setting::set('theme_vars_'.app(ThemeManager::class)->active(), $data['theme_vars'] ?? []);
$vars = [];
foreach ($data['theme_vars'] ?? [] as $row) {
$key = trim((string) ($row['key'] ?? ''));
if ($key === '') {
continue;
}
$vars[$key] = (string) ($row['value'] ?? '');
}
Setting::set('theme_vars_'.app(ThemeManager::class)->active(), $vars);
\App\Blog\Support\PageCacheMiddleware::flush();
Notification::make()->title('注入内容已保存')->success()->send();
+61
View File
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Livewire;
use App\Blog\Support\PageCacheMiddleware;
use Filament\Notifications\Notification;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Str;
use Livewire\Component;
/**
* 顶栏「同步旧站」按钮:点击后对 sablog 老库做增量导入(幂等,不产生重复)。
*/
class LegacySyncButton extends Component
{
public bool $running = false;
public function run(): void
{
$this->running = true;
$src = config('blog.sablog_source');
try {
Artisan::call('sablog:import', [
'--host' => $src['host'],
'--port' => $src['port'],
'--database' => $src['database'],
'--username' => $src['username'],
'--password' => $src['password'],
'--prefix' => $src['prefix'],
]);
$output = trim(Artisan::output());
PageCacheMiddleware::flush();
Notification::make()
->title('旧站数据同步完成')
->body(Str::afterLast($output, '报告:') ?: $output)
->success()
->persistent()
->send();
} catch (\Throwable $e) {
Notification::make()
->title('同步失败')
->body($e->getMessage())
->danger()
->persistent()
->send();
}
$this->running = false;
}
public function render(): \Illuminate\Contracts\View\View
{
return view('livewire.legacy-sync-button');
}
}
+29
View File
@@ -133,6 +133,35 @@ class Post extends Model implements HasMedia
return $cover->getUrl($conversion) ?: $cover->getUrl();
}
/**
* 封面回退链:显式封面 → 附件/正文里的第一张图。
* 都没有时返回 null,由调用方决定兜底(占位图或不显示)。
*/
public function coverOrFirstImageUrl(): ?string
{
if ($url = $this->coverImageUrl()) {
return $url;
}
// 附件里的第一张图片
$attachment = $this->getMedia('attachments')
->first(fn ($m) => str_starts_with((string) $m->mime_type, 'image/'));
if ($attachment) {
return $attachment->getUrl();
}
// 正文 HTML/Markdown 里的第一张图片地址
$content = (string) $this->content;
if (preg_match('/<img[^>]+src=["\']([^"\']+)["\']/i', $content, $m)) {
return $m[1];
}
if (preg_match('/!\[[^\]]*\]\((https?:\/\/[^)\s]+)[^)]*\)/i', $content, $m)) {
return $m[1];
}
return null;
}
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
@@ -48,41 +48,6 @@ class AdminPanelProvider extends PanelProvider
->sidebarWidth('13rem')
->maxContentWidth(\Filament\Support\Enums\Width::Full)
->userMenuItems([
// 工具:同步旧站数据(sablog 增量导入,约 1 分钟)
\Filament\Actions\Action::make('sablog-import')
->label('同步旧站数据')
->icon('heroicon-o-arrow-path')
->color('info')
->requiresConfirmation()
->modalDescription('从旧 sablog 库增量导入文章/评论/标签等(幂等,不会产生重复),约需 1 分钟,期间请不要重复点击。')
->action(function (): void {
$src = config('blog.sablog_source');
try {
\Illuminate\Support\Facades\Artisan::call('sablog:import', [
'--host' => $src['host'],
'--port' => $src['port'],
'--database' => $src['database'],
'--username' => $src['username'],
'--password' => $src['password'],
'--prefix' => $src['prefix'],
]);
$output = trim(\Illuminate\Support\Facades\Artisan::output());
\App\Blog\Support\PageCacheMiddleware::flush();
\Filament\Notifications\Notification::make()
->title('旧站数据同步完成')
->body(\Illuminate\Support\Str::afterLast($output, '报告:') ?: $output)
->success()
->persistent()
->send();
} catch (\Throwable $e) {
\Filament\Notifications\Notification::make()
->title('同步失败')
->body($e->getMessage())
->danger()
->persistent()
->send();
}
}),
// 工具:清空缓存(Livewire action,即时通知)
\Filament\Actions\Action::make('flush-page-cache')
->label('清空前台缓存')