feat: restore sablog browse modes and clean imported HTML
Build and Deploy / verify (push) Successful in 40s
Build and Deploy / deploy (push) Successful in 36s

Keep the public site readable after import (nbsp, highlighter, wrapping code, compact pager) and finish the admin extras already in this tree: sticky levels, preview, page cache, and live llms.txt.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
gouki
2026-09-18 21:33:02 +00:00
co-authored by Cursor
parent 469160dc3f
commit ae47447062
98 changed files with 8618 additions and 901 deletions
+8
View File
@@ -42,6 +42,7 @@ class BlogSettings extends Page
// 外观
'active_theme' => Setting::get('active_theme', config('themes.default')),
'per_page' => Setting::get('per_page', config('blog.per_page')),
'home_view_mode' => Setting::get('home_view_mode', config('blog.home_view_mode', 'list')),
'list_shownum' => Setting::get('list_shownum', '50'),
'listtime' => Setting::get('listtime', 'Y-m-d'),
'normaltime' => Setting::get('normaltime', 'Y-m-d H:i'),
@@ -102,6 +103,13 @@ class BlogSettings extends Page
->columnSpanFull()
->options(collect(app(\App\Blog\Support\ThemeManager::class)->all())->pluck('title', 'name')),
TextInput::make('per_page')->label('首页每页文章数')->numeric(),
Select::make('home_view_mode')
->label('首页浏览模式')
->options([
'list' => '列表(标题 + 摘要)',
'full' => '标准(全文)',
])
->helperText('前台右上角仍可临时切换,和老 sablog 的「标准 | 列表」一样'),
TextInput::make('list_shownum')->label('列表每页数量')->numeric()->helperText('分类/标签/搜索等列表页'),
TextInput::make('listtime')->label('列表时间格式')->placeholder('Y-m-d'),
TextInput::make('normaltime')->label('详情时间格式')->placeholder('Y-m-d H:i'),
+91
View File
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use Filament\Pages\Page;
use Illuminate\Support\Facades\File;
class Languages extends Page
{
protected static \UnitEnum|string|null $navigationGroup = '管理';
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-language';
protected static ?string $navigationLabel = '语言';
protected static ?string $title = '语言';
protected static ?string $slug = 'languages';
protected static ?int $navigationSort = 21;
protected string $view = 'filament.pages.languages';
/**
* @return array{
* locale: string,
* fallback: string,
* faker: string,
* app_files: list<string>,
* filament_locales: list<string>,
* content_i18n: bool
* }
*/
public function getInfoProperty(): array
{
return [
'locale' => app()->getLocale(),
'fallback' => (string) config('app.fallback_locale'),
'faker' => (string) config('app.faker_locale'),
'app_files' => $this->appLanguageFiles(),
'filament_locales' => $this->filamentLocales(),
'content_i18n' => false,
];
}
/**
* @return list<string>
*/
private function appLanguageFiles(): array
{
$found = [];
foreach (File::glob(lang_path('*.json')) as $file) {
$found[] = basename((string) $file, '.json');
}
foreach (File::directories(lang_path()) as $directory) {
$name = basename($directory);
if ($name === 'vendor') {
continue;
}
$found[] = $name;
}
$found = array_values(array_unique($found));
sort($found);
return $found;
}
/**
* @return list<string>
*/
private function filamentLocales(): array
{
$path = lang_path('vendor/filament');
if (! File::isDirectory($path)) {
return [];
}
$locales = array_map(
static fn (string $directory): string => basename($directory),
File::directories($path)
);
sort($locales);
return $locales;
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Blog\Support\PageCacheMiddleware;
use App\Models\Setting;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Support\Facades\Cache;
class PageCache extends Page
{
protected static \UnitEnum|string|null $navigationGroup = '管理';
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-bolt';
protected static ?string $navigationLabel = '页面缓存';
protected static ?string $title = '页面缓存';
protected static ?string $slug = 'page-cache';
protected static ?int $navigationSort = 20;
protected string $view = 'filament.pages.page-cache';
/**
* @return array{hits: int, misses: int, count: int, rate: float, ttl: int}
*/
public function getStatsProperty(): array
{
return [
...PageCacheMiddleware::stats(),
'ttl' => (int) config('blog.page_cache_ttl', 300),
];
}
/**
* @return list<array{key: string, url: string, cached_at: string}>
*/
public function getEntriesProperty(): array
{
return PageCacheMiddleware::entries();
}
public function flushPageCache(): void
{
PageCacheMiddleware::flush();
Notification::make()->title('前台页面缓存已清空')->success()->send();
}
public function flushAllCache(): void
{
PageCacheMiddleware::flush();
Setting::flushCache();
Cache::flush();
Notification::make()->title('全部缓存已清空')->success()->send();
}
public function resetStats(): void
{
PageCacheMiddleware::resetStats();
Notification::make()->title('命中统计已重置')->success()->send();
}
public function forgetEntry(string $key): void
{
PageCacheMiddleware::forget($key);
Notification::make()->title('已删除这条缓存')->success()->send();
}
protected function getHeaderActions(): array
{
return [
Action::make('resetStats')
->label('重置统计')
->color('gray')
->action(fn () => $this->resetStats()),
Action::make('flushPageCache')
->label('清空前台缓存')
->color('warning')
->action(fn () => $this->flushPageCache()),
Action::make('flushAllCache')
->label('清空全部缓存')
->color('danger')
->requiresConfirmation()
->action(fn () => $this->flushAllCache()),
];
}
}
@@ -46,7 +46,8 @@ class CategoryForm
->label('封面图')
->module('articles')
->mediaType('image')
->relationship('coverMedia'),
->relationship('coverMedia')
->extraFieldWrapperAttributes(['class' => 'll-media-picker-preview']),
]),
]);
}
@@ -2,10 +2,10 @@
declare(strict_types=1);
namespace App\Filament\Resources\Posts\Pages;
use App\Filament\Resources\Posts\PostResource;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
@@ -16,6 +16,12 @@ class EditPost extends EditRecord
protected function getHeaderActions(): array
{
return [
Action::make('preview')
->label('预览')
->icon('heroicon-o-eye')
->color('gray')
->url(fn (): string => $this->record->url)
->openUrlInNewTab(),
DeleteAction::make(),
];
}
@@ -5,10 +5,11 @@ declare(strict_types=1);
namespace App\Filament\Resources\Posts\RelationManagers;
use App\Models\Comment;
use Filament\Forms\Components\TextInput;
use Filament\Actions\Action;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Actions\Action;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
@@ -22,7 +23,7 @@ class CommentsRelationManager extends RelationManager
protected static ?string $pluralModelLabel = '评论';
public function form(\Filament\Schemas\Schema $schema): \Filament\Schemas\Schema
public function form(Schema $schema): Schema
{
return $schema
->components([
+270 -104
View File
@@ -5,129 +5,295 @@ declare(strict_types=1);
namespace App\Filament\Resources\Posts\Schemas;
use App\Blog\Support\MarkdownMediaEmbed;
use App\Models\Post;
use Filament\Actions\Action;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\MarkdownEditor;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\ToggleButtons;
use Filament\Notifications\Notification;
use Filament\Schemas\Components\Actions;
use Filament\Schemas\Components\Component;
use Filament\Schemas\Components\Group;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema;
use ReflectionClass;
use Xpier\FilamentMediaLibrary\Components\MediaPicker;
use Xpier\FilamentMediaLibrary\Models\MediaLibrary;
class PostForm
{
public const SIDEBAR_KEY = 'post_form_sidebar';
public const ACCESS_KEY = 'post_form_access';
public static function configure(Schema $schema): Schema
{
return $schema
->columns(self::pcColumns(3))
->components([
\Filament\Schemas\Components\Section::make('内容')
->columns(2)
->schema([
TextInput::make('title')
->label('标题')
->required()
->columnSpanFull(),
TextInput::make('slug')
->label('Slug(留空自动生成)'),
Select::make('category_id')
->label('分类')
->relationship('category', 'name'),
Select::make('content_format')
->label('内容格式')
->options([
'markdown' => 'Markdown',
'html' => 'HTML',
])
->default('markdown')
->live(),
Select::make('status')
->label('状态')
->options([
'published' => '已发布',
'draft' => '草稿',
'private' => '私密',
])
->default('published'),
MarkdownEditor::make('content')
->label('正文')
->required()
->helperText('Markdown 插入图片用 ![说明](media:ID),文件用 [文件名](media:ID)。不要把 CDN / 对象存储地址写进正文。')
->fileAttachments(false)
->columnSpanFull(),
MediaPicker::make('insert_media_id')
->label('从媒体库插入正文')
->module('articles')
->mediaType(null)
->dehydrated(false)
->helperText('选好媒体后点「插入到正文」。Markdown 写入 media:ID 引用,渲染时再换成真实地址。')
->columnSpanFull(),
Actions::make([
Action::make('insertSelectedMedia')
->label('插入到正文')
->action(function (Get $get, Set $set): void {
$mediaId = $get('insert_media_id');
$media = is_numeric($mediaId)
? MediaLibrary::query()->find((int) $mediaId)
: null;
if (! $media instanceof MediaLibrary) {
Notification::make()->title('请先从媒体库选择文件')->warning()->send();
return;
}
$snippet = app(MarkdownMediaEmbed::class)->snippet(
$media,
(string) ($get('content_format') ?: 'markdown')
);
$set('content', rtrim((string) $get('content'))."\n\n".$snippet."\n");
$set('insert_media_id', null);
Notification::make()->title('已写入正文引用')->success()->send();
}),
])->columnSpanFull(),
]),
\Filament\Schemas\Components\Section::make('封面与摘要')
->columns(2)
->schema([
MediaPicker::make('cover_media_id')
->label('封面图')
->module('articles')
->mediaType('image')
->relationship('coverMedia')
->columnSpanFull(),
Textarea::make('excerpt')
->label('摘要(留空自动截取)')
->rows(3),
TextInput::make('keywords')
->label('关键词(逗号分隔)'),
]),
\Filament\Schemas\Components\Section::make('发布设置')
->columns(3)
->schema([
DateTimePicker::make('published_at')
->label('发布时间')
->default(now()),
Toggle::make('is_sticky')
->label('置顶'),
Toggle::make('close_comment')
->label('关闭评论'),
TextInput::make('read_password')
->label('阅读密码(留空公开)'),
TextInput::make('views')
->label('浏览量')
->numeric()
->disabled(),
TextInput::make('comment_count')
->label('评论数')
->numeric()
->disabled(),
]),
self::contentSection(),
self::sidebarGroup(),
]);
}
/**
* Append sections or fields into the right-hand sidebar (2:1 layout).
*
* @param array<Component> $components
*/
public static function appendToSidebar(Schema $schema, array $components): void
{
$sidebar = self::findKeyedComponent($schema->getComponents(), self::SIDEBAR_KEY);
if (! $sidebar instanceof Component) {
$schema->components([
...$schema->getComponents(),
...$components,
]);
return;
}
$existing = $sidebar->getDefaultChildComponents();
if ($existing instanceof Schema) {
$existing->components([
...$existing->getComponents(),
...$components,
]);
return;
}
$sidebar->schema([
...(is_array($existing) ? $existing : []),
...$components,
]);
}
/**
* Append fields into the access group (password / members / price).
*
* @param array<Component> $fields
*/
public static function appendToAccessSection(Schema $schema, array $fields): void
{
$sidebar = self::findKeyedComponent($schema->getComponents(), self::SIDEBAR_KEY);
$access = $sidebar instanceof Component
? self::findKeyedComponent(self::childList($sidebar), self::ACCESS_KEY)
: null;
if (! $access instanceof Component) {
self::appendToSidebar($schema, [
Section::make('访问设置')
->key(self::ACCESS_KEY)
->schema($fields),
]);
return;
}
$existing = $access->getDefaultChildComponents();
if ($existing instanceof Schema) {
$existing->components([
...$existing->getComponents(),
...$fields,
]);
return;
}
$access->schema([
...(is_array($existing) ? $existing : []),
...$fields,
]);
}
private static function contentSection(): Section
{
return Section::make('正文')
->columnSpan(self::pcColumns(2))
->columns(self::pcColumns(3))
->schema([
TextInput::make('title')
->label('标题')
->required()
->columnSpanFull(),
TextInput::make('slug')
->label('Slug(留空自动生成)')
->columnSpanFull(),
Select::make('category_id')
->label('分类')
->relationship('category', 'name'),
ToggleButtons::make('content_format')
->label('内容格式')
->options([
'markdown' => 'Markdown',
'html' => 'HTML',
])
->default('markdown')
->inline()
->grouped()
->live(),
ToggleButtons::make('status')
->label('状态')
->options([
'published' => '已发布',
'draft' => '草稿',
'private' => '私密',
])
->default('published')
->inline()
->grouped(),
MediaPicker::make('cover_media_id')
->label('封面图')
->module('articles')
->mediaType('image')
->relationship('coverMedia')
->extraFieldWrapperAttributes(['class' => 'll-media-picker-preview'])
->columnSpanFull(),
MarkdownEditor::make('content')
->label('正文')
->required()
->minHeight('32rem')
->helperText('Markdown 插入图片用 ![说明](media:ID),文件用 [文件名](media:ID)。不要把 CDN / 对象存储地址写进正文。')
->fileAttachments(false)
->columnSpanFull(),
MediaPicker::make('insert_media_id')
->label('从媒体库插入正文')
->module('articles')
->mediaType(null)
->dehydrated(false)
->extraFieldWrapperAttributes(['class' => 'll-media-picker-preview'])
->helperText('选好媒体后点「插入到正文」。Markdown 写入 media:ID 引用,渲染时再换成真实地址。')
->columnSpanFull(),
Actions::make([
Action::make('insertSelectedMedia')
->label('插入到正文')
->action(function (Get $get, Set $set): void {
$mediaId = $get('insert_media_id');
$media = is_numeric($mediaId)
? MediaLibrary::query()->find((int) $mediaId)
: null;
if (! $media instanceof MediaLibrary) {
Notification::make()->title('请先从媒体库选择文件')->warning()->send();
return;
}
$snippet = app(MarkdownMediaEmbed::class)->snippet(
$media,
(string) ($get('content_format') ?: 'markdown')
);
$set('content', rtrim((string) $get('content'))."\n\n".$snippet."\n");
$set('insert_media_id', null);
Notification::make()->title('已写入正文引用')->success()->send();
}),
])->columnSpanFull(),
]);
}
private static function sidebarGroup(): Group
{
return Group::make([
Section::make('发布设置')
->schema([
DateTimePicker::make('published_at')
->label('发布时间')
->default(now()),
ToggleButtons::make('sticky_level')
->label('置顶等级')
->options(Post::stickyLevelOptions())
->default(Post::STICKY_NONE)
->inline()
->grouped()
->helperText('轻置顶压过普通文章,强置顶压过轻置顶,置顶头条永远排在最上面。'),
ToggleButtons::make('close_comment')
->label('评论状态')
->boolean('关闭评论', '开放评论')
->default(false)
->inline()
->grouped()
->dehydrateStateUsing(fn (mixed $state): bool => (bool) $state),
Textarea::make('excerpt')
->label('摘要(留空自动截取)')
->rows(4),
TextInput::make('keywords')
->label('关键词(逗号分隔)'),
]),
Section::make('访问设置')
->key(self::ACCESS_KEY)
->schema([
TextInput::make('read_password')
->label('阅读密码(留空公开)'),
]),
])
->key(self::SIDEBAR_KEY)
->columnSpan(self::pcColumns(1));
}
/**
* Admin writing is almost always on a PC. Filament's columns(3) only
* applies from the lg breakpoint, so we pin every breakpoint.
*
* @return array<string, int>
*/
private static function pcColumns(int $count): array
{
return [
'default' => $count,
'sm' => $count,
'md' => $count,
'lg' => $count,
'xl' => $count,
'2xl' => $count,
];
}
/**
* @param array<int, mixed> $components
*/
private static function findKeyedComponent(array $components, string $key): ?Component
{
foreach ($components as $component) {
if (! $component instanceof Component) {
continue;
}
if (self::rawKey($component) === $key) {
return $component;
}
}
return null;
}
private static function rawKey(Component $component): ?string
{
$property = (new ReflectionClass($component))->getProperty('key');
$key = $property->getValue($component);
return is_string($key) ? $key : null;
}
/**
* @return array<int, mixed>
*/
private static function childList(Component $component): array
{
$children = $component->getDefaultChildComponents();
if ($children instanceof Schema) {
return $children->getComponents();
}
return is_array($children) ? $children : [];
}
}
@@ -2,18 +2,18 @@
declare(strict_types=1);
namespace App\Filament\Resources\Posts\Tables;
use App\Models\Post;
use Filament\Actions\Action;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\Select;
use Filament\Tables\Columns\IconColumn;
use Filament\Support\Enums\Width;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Enums\FiltersLayout;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
class PostsTable
@@ -46,9 +46,17 @@ class PostsTable
'draft' => 'warning',
default => 'gray',
}),
IconColumn::make('is_sticky')
TextColumn::make('sticky_level')
->label('置顶')
->boolean(),
->badge()
->sortable()
->formatStateUsing(fn ($state) => Post::stickyLevelOptions()[(int) $state] ?? '不置顶')
->color(fn ($state) => match ((int) $state) {
Post::STICKY_HEADLINE => 'danger',
Post::STICKY_STRONG => 'warning',
Post::STICKY_LIGHT => 'success',
default => 'gray',
}),
TextColumn::make('views')
->label('阅读')
->numeric()
@@ -79,12 +87,17 @@ class PostsTable
'markdown' => 'Markdown',
'html' => 'HTML',
]),
TernaryFilter::make('is_sticky')
->label('置顶'),
], \Filament\Tables\Enums\FiltersLayout::AboveContent)
SelectFilter::make('sticky_level')
->label('置顶')
->options(Post::stickyLevelOptions()),
], FiltersLayout::AboveContent)
->filtersFormColumns(3)
->filtersFormWidth(\Filament\Support\Enums\Width::ThreeExtraLarge)
->filtersFormWidth(Width::ThreeExtraLarge)
->recordActions([
Action::make('preview')
->label('预览')
->url(fn (Post $record): string => $record->url)
->openUrlInNewTab(),
ViewAction::make(),
EditAction::make(),
])
+4 -2
View File
@@ -2,9 +2,10 @@
declare(strict_types=1);
namespace App\Filament\Widgets;
use App\Blog\Support\PageCacheMiddleware;
use App\Filament\Pages\PageCache;
use App\Models\Comment;
use App\Models\Post;
use App\Models\User;
@@ -17,7 +18,7 @@ class BlogStats extends BaseWidget
protected function getStats(): array
{
$cache = \App\Blog\Support\PageCacheMiddleware::stats();
$cache = PageCacheMiddleware::stats();
return [
Stat::make('文章', Post::published()->count())
@@ -30,6 +31,7 @@ class BlogStats extends BaseWidget
->icon('heroicon-o-users'),
Stat::make('页面缓存', $cache['count'].' 条')
->description('命中率 '.$cache['rate'].'%(命中 '.$cache['hits'].' / 未命中 '.$cache['misses'].',按日滚动)')
->url(PageCache::getUrl())
->icon('heroicon-o-bolt'),
];
}