feat: topbar legacy-sync button, card view mode with cover fallback, year/month/day archives, richer page cache admin, stylevar repeater editor
- 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:
@@ -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'));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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('清空前台缓存')
|
||||
|
||||
Reference in New Issue
Block a user