feat: restore sablog browse modes and clean imported HTML
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:
@@ -34,11 +34,12 @@ class ArchiveController extends Controller
|
||||
$start = Carbon::create((int) $year, (int) $month, 1)->startOfMonth();
|
||||
$end = (clone $start)->endOfMonth();
|
||||
|
||||
$posts = Post::query()
|
||||
->whereBetween('published_at', [$start, $end])
|
||||
->published()
|
||||
->orderByDesc('published_at')
|
||||
->paginate((int) blog_setting('per_page', config('blog.per_page')));
|
||||
$posts = blog_paginate(
|
||||
Post::query()
|
||||
->whereBetween('published_at', [$start, $end])
|
||||
->published()
|
||||
->orderByDesc('published_at')
|
||||
);
|
||||
|
||||
return theme_view('list', compact('posts'))
|
||||
->with('archiveTitle', "{$year} 年 {$month} 月");
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Blog\Controllers;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Post;
|
||||
|
||||
class CategoryController extends Controller
|
||||
{
|
||||
@@ -14,10 +12,9 @@ class CategoryController extends Controller
|
||||
{
|
||||
$category = Category::query()->where('slug', $slug)->orWhere('id', (int) $slug)->firstOrFail();
|
||||
|
||||
$posts = $category->posts()
|
||||
->published()
|
||||
->latest('published_at')
|
||||
->paginate((int) blog_setting('per_page', config('blog.per_page')));
|
||||
$posts = blog_paginate(
|
||||
$category->posts()->published()->orderedForList()
|
||||
);
|
||||
|
||||
return theme_view('list', compact('category', 'posts'));
|
||||
}
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Blog\Controllers;
|
||||
|
||||
use App\Models\Post;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class FeedController extends Controller
|
||||
{
|
||||
@@ -54,7 +52,9 @@ class FeedController extends Controller
|
||||
foreach ($disallow as $path) {
|
||||
$lines[] = "Disallow: {$path}";
|
||||
}
|
||||
$lines[] = "Sitemap: ".url($sitemap);
|
||||
$lines[] = 'Sitemap: '.url($sitemap);
|
||||
$lines[] = 'Allow: /llms.txt';
|
||||
$lines[] = 'Allow: /posts/*.md';
|
||||
|
||||
return response(implode("\n", $lines)."\n")->header('Content-Type', 'text/plain; charset=utf-8');
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Blog\Controllers;
|
||||
|
||||
use App\Models\Post;
|
||||
@@ -11,11 +10,9 @@ class HomeController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$posts = Post::query()
|
||||
->published()
|
||||
->orderByDesc('is_sticky')
|
||||
->latest('published_at')
|
||||
->paginate((int) blog_setting('per_page', config('blog.per_page')));
|
||||
$posts = blog_paginate(
|
||||
Post::query()->published()->orderedForList()
|
||||
);
|
||||
|
||||
return theme_view('index', compact('posts'));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Blog\Controllers;
|
||||
|
||||
use App\Models\Post;
|
||||
use Illuminate\Support\Str;
|
||||
use League\HTMLToMarkdown\HtmlConverter;
|
||||
|
||||
class LlmsController extends Controller
|
||||
{
|
||||
/**
|
||||
* /llms.txt — live index for agents. Not a static file.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$siteName = (string) blog_setting('site_name', config('blog.name'));
|
||||
$siteDescription = (string) blog_setting('site_description', config('blog.description'));
|
||||
|
||||
$posts = Post::query()
|
||||
->published()
|
||||
->latest('published_at')
|
||||
->limit(200)
|
||||
->get();
|
||||
|
||||
$lines = [
|
||||
'# '.$siteName,
|
||||
'',
|
||||
'> '.$siteDescription,
|
||||
'',
|
||||
'## Feeds',
|
||||
'',
|
||||
'- [RSS]('.route('feed.rss').')',
|
||||
'- [Sitemap]('.route('feed.sitemap').')',
|
||||
'- [llms.txt]('.route('feed.llms').')',
|
||||
'',
|
||||
'## Articles',
|
||||
'',
|
||||
];
|
||||
|
||||
foreach ($posts as $post) {
|
||||
if ($this->isRestricted($post)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$summary = Str::limit(trim(preg_replace('/\s+/', ' ', strip_tags($post->excerpt_or_fallback)) ?? ''), 120);
|
||||
$markdownUrl = $this->markdownUrl($post);
|
||||
$htmlUrl = $post->url;
|
||||
$title = $post->title;
|
||||
|
||||
$lines[] = $markdownUrl
|
||||
? '- ['.$title.']('.$markdownUrl.') — '.$summary.' ([html]('.$htmlUrl.'))'
|
||||
: '- ['.$title.']('.$htmlUrl.') — '.$summary;
|
||||
}
|
||||
|
||||
return response(implode("\n", $lines)."\n", 200, [
|
||||
'Content-Type' => 'text/plain; charset=utf-8',
|
||||
'Cache-Control' => 'no-store',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* /posts/{slug}.md — full markdown for a published public post.
|
||||
*/
|
||||
public function postMarkdown(string $slug)
|
||||
{
|
||||
$post = Post::query()
|
||||
->where('slug', $slug)
|
||||
->orWhere('id', (int) $slug)
|
||||
->first();
|
||||
|
||||
abort_unless($post && $post->status === 'published' && ! $this->isRestricted($post), 404);
|
||||
|
||||
$body = $post->content_format === 'markdown'
|
||||
? (string) $post->content
|
||||
: $this->htmlToMarkdown((string) $post->content);
|
||||
|
||||
$out = '# '.$post->title."\n\n";
|
||||
if ($post->excerpt) {
|
||||
$out .= '> '.$post->excerpt."\n\n";
|
||||
}
|
||||
$out .= $body."\n";
|
||||
|
||||
return response($out, 200, [
|
||||
'Content-Type' => 'text/markdown; charset=utf-8',
|
||||
'Cache-Control' => 'no-store',
|
||||
]);
|
||||
}
|
||||
|
||||
private function isRestricted(Post $post): bool
|
||||
{
|
||||
if (filled($post->read_password)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ! empty(($post->meta ?? [])['members_only']);
|
||||
}
|
||||
|
||||
private function markdownUrl(Post $post): ?string
|
||||
{
|
||||
$slug = trim((string) $post->slug);
|
||||
|
||||
if ($slug === '') {
|
||||
return route('geo.post.markdown', ['slug' => (string) $post->id]);
|
||||
}
|
||||
|
||||
return route('geo.post.markdown', ['slug' => $slug]);
|
||||
}
|
||||
|
||||
private function htmlToMarkdown(string $html): string
|
||||
{
|
||||
$converter = new HtmlConverter([
|
||||
'strip_tags' => false,
|
||||
'hard_break' => true,
|
||||
]);
|
||||
|
||||
return $converter->convert($html);
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,15 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Blog\Controllers;
|
||||
|
||||
use App\Blog\Services\PostContentRenderer;
|
||||
use App\Models\Post;
|
||||
use Illuminate\Pagination\Paginator;
|
||||
|
||||
class PostController extends Controller
|
||||
{
|
||||
public function __construct(private PostContentRenderer $renderer)
|
||||
{
|
||||
}
|
||||
public function __construct(private PostContentRenderer $renderer) {}
|
||||
|
||||
public function show(string $id, ?string $page = null)
|
||||
{
|
||||
@@ -21,13 +19,16 @@ class PostController extends Controller
|
||||
->orWhere('slug', $id)
|
||||
->first();
|
||||
|
||||
abort_unless($post && $post->status === 'published', 404);
|
||||
abort_unless($post, 404);
|
||||
|
||||
$canPreview = $this->canPreviewUnpublished();
|
||||
abort_unless($post->status === 'published' || $canPreview, 404);
|
||||
|
||||
$this->ensureReadable($post);
|
||||
|
||||
// sablog 式 /show-{id}-{page}.shtml 的评论分页
|
||||
if ($page !== null) {
|
||||
\Illuminate\Pagination\Paginator::currentPageResolver(fn () => max(1, (int) $page));
|
||||
Paginator::currentPageResolver(fn () => max(1, (int) $page));
|
||||
}
|
||||
|
||||
// 浏览量由 /track-view 端点统计(页面整页缓存下控制器不执行,避免双计)
|
||||
@@ -44,9 +45,17 @@ class PostController extends Controller
|
||||
'comments' => $comments,
|
||||
'contentHtml' => $rendered['html'],
|
||||
'toc' => $rendered['toc'],
|
||||
'isPreview' => $post->status !== 'published' && $canPreview,
|
||||
]);
|
||||
}
|
||||
|
||||
private function canPreviewUnpublished(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
return $user && ($user->isAdmin() || $user->hasRole('editor'));
|
||||
}
|
||||
|
||||
private function ensureReadable(Post $post): void
|
||||
{
|
||||
if (! $post->read_password) {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Blog\Controllers;
|
||||
|
||||
use App\Models\Post;
|
||||
@@ -13,11 +12,9 @@ class SearchController extends Controller
|
||||
{
|
||||
$keyword = trim(request('q', request('keyword', '')));
|
||||
|
||||
$posts = Post::query()
|
||||
->published()
|
||||
->search($keyword)
|
||||
->orderByDesc('published_at')
|
||||
->paginate((int) blog_setting('per_page', config('blog.per_page')));
|
||||
$posts = blog_paginate(
|
||||
Post::query()->published()->search($keyword)->orderedForList()
|
||||
);
|
||||
|
||||
return theme_view('search', compact('posts', 'keyword'));
|
||||
}
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Blog\Controllers;
|
||||
|
||||
use App\Models\Post;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Spatie\Tags\Tag;
|
||||
|
||||
class TagController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$counts = \Illuminate\Support\Facades\DB::table('taggables')
|
||||
$counts = DB::table('taggables')
|
||||
->selectRaw('tag_id, COUNT(*) as total')
|
||||
->where('taggable_type', Post::class)
|
||||
->groupBy('tag_id')
|
||||
@@ -37,11 +37,12 @@ class TagController extends Controller
|
||||
|
||||
abort_unless($tag, 404);
|
||||
|
||||
$posts = Post::query()
|
||||
->whereHas('tags', fn ($q) => $q->where('tags.id', $tag->id))
|
||||
->published()
|
||||
->latest('published_at')
|
||||
->paginate((int) blog_setting('per_page', config('blog.per_page')));
|
||||
$posts = blog_paginate(
|
||||
Post::query()
|
||||
->whereHas('tags', fn ($q) => $q->where('tags.id', $tag->id))
|
||||
->published()
|
||||
->orderedForList()
|
||||
);
|
||||
|
||||
return theme_view('tag', compact('tag', 'posts'));
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ declare(strict_types=1);
|
||||
namespace App\Blog\Services;
|
||||
|
||||
use App\Blog\Support\MarkdownMediaEmbed;
|
||||
use App\Blog\Support\SablogHtmlNormalizer;
|
||||
use App\Models\Post;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use League\CommonMark\Environment\Environment;
|
||||
@@ -38,7 +39,7 @@ class PostContentRenderer
|
||||
public function render(Post $post): string
|
||||
{
|
||||
$embed = app(MarkdownMediaEmbed::class);
|
||||
$raw = (string) $post->content;
|
||||
$raw = app(SablogHtmlNormalizer::class)->normalize((string) $post->content);
|
||||
|
||||
$content = $post->content_format === 'markdown'
|
||||
? $this->toHtml($embed->expandMarkdown($raw))
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Blog\Support;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Homepage / list browse mode, matching old SaBlog 标准 | 列表.
|
||||
*/
|
||||
class HomeViewMode
|
||||
{
|
||||
public const LIST = 'list';
|
||||
|
||||
public const FULL = 'full';
|
||||
|
||||
public const COOKIE = 'll_home_view';
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function options(): array
|
||||
{
|
||||
return [
|
||||
self::LIST => '列表',
|
||||
self::FULL => '标准',
|
||||
];
|
||||
}
|
||||
|
||||
public static function isValid(mixed $value): bool
|
||||
{
|
||||
return is_string($value) && in_array($value, [self::LIST, self::FULL], true);
|
||||
}
|
||||
|
||||
public static function resolve(?Request $request = null): string
|
||||
{
|
||||
$request ??= request();
|
||||
|
||||
foreach ([
|
||||
$request->query('view'),
|
||||
$request->cookie(self::COOKIE),
|
||||
blog_setting('home_view_mode', config('blog.home_view_mode', self::LIST)),
|
||||
] as $value) {
|
||||
if (self::isValid($value)) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return self::LIST;
|
||||
}
|
||||
|
||||
public static function current(): string
|
||||
{
|
||||
$request = request();
|
||||
$mode = self::resolve($request);
|
||||
|
||||
if (self::isValid($request->query('view'))) {
|
||||
cookie()->queue(cookie(self::COOKIE, (string) $request->query('view'), 60 * 24 * 30));
|
||||
}
|
||||
|
||||
return $mode;
|
||||
}
|
||||
|
||||
public static function url(string $mode): string
|
||||
{
|
||||
return request()->fullUrlWithQuery(['view' => $mode]);
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ class PageCacheMiddleware
|
||||
'body' => $response->getContent(),
|
||||
'type' => $response->headers->get('Content-Type') ?: 'text/html; charset=utf-8',
|
||||
], $ttl);
|
||||
self::rememberKey($key);
|
||||
self::rememberKey($key, $request->fullUrl());
|
||||
}
|
||||
|
||||
return $response;
|
||||
@@ -53,8 +53,8 @@ class PageCacheMiddleware
|
||||
|
||||
public static function flush(): void
|
||||
{
|
||||
foreach ((array) Cache::get(self::TAG.'_keys', []) as $key) {
|
||||
Cache::forget($key);
|
||||
foreach (array_keys((array) Cache::get(self::TAG.'_keys', [])) as $key) {
|
||||
Cache::forget((string) $key);
|
||||
}
|
||||
|
||||
Cache::forget(self::TAG.'_keys');
|
||||
@@ -88,16 +88,50 @@ class PageCacheMiddleware
|
||||
Cache::forget(self::TAG.'_misses');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{key: string, url: string, cached_at: string}>
|
||||
*/
|
||||
public static function entries(): array
|
||||
{
|
||||
$entries = [];
|
||||
|
||||
foreach ((array) Cache::get(self::TAG.'_keys', []) as $key => $meta) {
|
||||
$entries[] = [
|
||||
'key' => (string) $key,
|
||||
'url' => is_array($meta) ? (string) ($meta['url'] ?? $key) : (string) $key,
|
||||
'cached_at' => is_array($meta) ? (string) ($meta['cached_at'] ?? '') : '',
|
||||
];
|
||||
}
|
||||
|
||||
usort($entries, fn (array $left, array $right): int => strcmp($right['cached_at'], $left['cached_at']));
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
public static function forget(string $key): void
|
||||
{
|
||||
Cache::forget($key);
|
||||
$keys = (array) Cache::get(self::TAG.'_keys', []);
|
||||
unset($keys[$key]);
|
||||
Cache::put(self::TAG.'_keys', $keys, now()->addDay());
|
||||
}
|
||||
|
||||
private static function increment(string $metric): void
|
||||
{
|
||||
$key = self::TAG.'_'.$metric;
|
||||
Cache::put($key, (int) Cache::get($key, 0) + 1, now()->addDay());
|
||||
}
|
||||
|
||||
private static function rememberKey(string $key): void
|
||||
private static function rememberKey(string $key, ?string $url = null): void
|
||||
{
|
||||
$keys = (array) Cache::get(self::TAG.'_keys', []);
|
||||
$keys[$key] = true;
|
||||
$previous = $keys[$key] ?? [];
|
||||
|
||||
$keys[$key] = [
|
||||
'url' => $url ?: (is_array($previous) ? (string) ($previous['url'] ?? '') : ''),
|
||||
'cached_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
Cache::put(self::TAG.'_keys', $keys, now()->addDay());
|
||||
}
|
||||
|
||||
@@ -117,16 +151,24 @@ class PageCacheMiddleware
|
||||
return false;
|
||||
}
|
||||
|
||||
// 动态端点
|
||||
// 动态端点、订阅和 agent 索引不进整页缓存,新文章要马上能看到
|
||||
if (str_starts_with($path, 'track-view')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (in_array($path, ['rss.xml', 'rss.php', 'sitemap.xml', 'robots.txt', 'llms.txt'], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (str_ends_with($path, '.md')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function key(Request $request): string
|
||||
{
|
||||
return self::TAG.':'.md5($request->fullUrl());
|
||||
return self::TAG.':'.md5($request->fullUrl().'|view:'.HomeViewMode::resolve($request));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Blog\Support;
|
||||
|
||||
use App\Blog\Services\PostContentRenderer;
|
||||
use DOMDocument;
|
||||
use DOMElement;
|
||||
use DOMNode;
|
||||
use DOMXPath;
|
||||
use League\HTMLToMarkdown\HtmlConverter;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Cleans leftover SaBlog-X HTML so imported posts render without literal
|
||||
* and without SyntaxHighlighter markup stretching the page.
|
||||
*/
|
||||
class SablogHtmlNormalizer
|
||||
{
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function languageAliases(): array
|
||||
{
|
||||
return [
|
||||
'js' => 'javascript',
|
||||
'javascript' => 'javascript',
|
||||
'xml' => 'xml',
|
||||
'html' => 'xml',
|
||||
'xhtml' => 'xml',
|
||||
'css' => 'css',
|
||||
'php' => 'php',
|
||||
'sql' => 'sql',
|
||||
'java' => 'java',
|
||||
'py' => 'python',
|
||||
'python' => 'python',
|
||||
'c#' => 'csharp',
|
||||
'cs' => 'csharp',
|
||||
'bash' => 'bash',
|
||||
'shell' => 'bash',
|
||||
'sh' => 'bash',
|
||||
'text' => 'text',
|
||||
];
|
||||
}
|
||||
|
||||
public function normalize(string $content): string
|
||||
{
|
||||
if ($content === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$content = $this->decodeNbsp($content);
|
||||
|
||||
if (! preg_match('/<\s*(div|p|ol|ul|li|pre|code|span|br|table)\b/i', $content)) {
|
||||
return $this->recoverFlattenedShell($content);
|
||||
}
|
||||
|
||||
try {
|
||||
$content = $this->transformHtml($content);
|
||||
} catch (Throwable) {
|
||||
// Keep the decoded text when HTML parsing fails.
|
||||
}
|
||||
|
||||
return $this->recoverFlattenedShell($content);
|
||||
}
|
||||
|
||||
public function toMarkdown(string $html, HtmlConverter $converter, PostContentRenderer $renderer): string
|
||||
{
|
||||
return $converter->convert($renderer->toMarkdownSafeTokens($this->normalize($html)));
|
||||
}
|
||||
|
||||
public function plainText(string $content): string
|
||||
{
|
||||
$text = $this->decodeNbsp(strip_tags($content));
|
||||
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$text = str_replace("\xC2\xA0", ' ', $text);
|
||||
$text = preg_replace('/\s+/u', ' ', $text) ?? $text;
|
||||
|
||||
return trim($text);
|
||||
}
|
||||
|
||||
public function decodeNbsp(string $content): string
|
||||
{
|
||||
$content = str_replace(
|
||||
['&nbsp;', ' ', ' ', ' ', ' ', "\xC2\xA0"],
|
||||
' ',
|
||||
$content
|
||||
);
|
||||
|
||||
return str_replace('&nbsp;', ' ', $content);
|
||||
}
|
||||
|
||||
private function transformHtml(string $html): string
|
||||
{
|
||||
$document = $this->loadDocument($html);
|
||||
$xpath = new DOMXPath($document);
|
||||
|
||||
$this->convertHighlighterBlocks($document, $xpath);
|
||||
$this->convertHighlighterLists($document, $xpath);
|
||||
$this->convertStackedCodeDivs($document);
|
||||
$this->removeEmptyBlocks($xpath);
|
||||
|
||||
return $this->innerHtml($document);
|
||||
}
|
||||
|
||||
private function convertHighlighterBlocks(DOMDocument $document, DOMXPath $xpath): void
|
||||
{
|
||||
$nodes = [];
|
||||
foreach ($xpath->query('//*[contains(concat(" ", normalize-space(@class), " "), " codeText ")]') ?: [] as $node) {
|
||||
if ($node instanceof DOMElement) {
|
||||
$nodes[] = $node;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($nodes as $node) {
|
||||
if ($node->parentNode === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->replaceWithPre($document, $xpath, $node);
|
||||
}
|
||||
}
|
||||
|
||||
private function convertHighlighterLists(DOMDocument $document, DOMXPath $xpath): void
|
||||
{
|
||||
$lists = [];
|
||||
foreach ($xpath->query('//ol[contains(@class, "dp-")]') ?: [] as $node) {
|
||||
if (! $node instanceof DOMElement || $node->parentNode === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->closestTag($node, 'pre') !== null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lists[] = $node;
|
||||
}
|
||||
|
||||
foreach ($lists as $list) {
|
||||
$this->replaceWithPre($document, $xpath, $list);
|
||||
}
|
||||
}
|
||||
|
||||
private function convertStackedCodeDivs(DOMDocument $document): void
|
||||
{
|
||||
$xpath = new DOMXPath($document);
|
||||
$parents = [];
|
||||
foreach ($xpath->query('//*') ?: [] as $node) {
|
||||
if ($node instanceof DOMElement) {
|
||||
$parents[] = $node;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($parents as $parent) {
|
||||
if ($parent->parentNode === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array(strtolower($parent->nodeName), ['pre', 'code'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$run = [];
|
||||
$flush = function () use (&$run, $document): void {
|
||||
if (count($run) < 2) {
|
||||
$run = [];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$lines = array_map(fn (DOMElement $div): string => $this->plainText($this->innerHtmlOf($div)), $run);
|
||||
if (! $this->looksLikeCodeLines($lines)) {
|
||||
$run = [];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$first = $run[0];
|
||||
$pre = $this->makePre($document, implode("\n", $lines), $this->detectLanguage('', implode("\n", $lines)));
|
||||
$first->parentNode?->insertBefore($pre, $first);
|
||||
foreach ($run as $div) {
|
||||
$div->parentNode?->removeChild($div);
|
||||
}
|
||||
$run = [];
|
||||
};
|
||||
|
||||
foreach (iterator_to_array($parent->childNodes) as $child) {
|
||||
if ($child instanceof DOMNode && $child->nodeType === XML_TEXT_NODE && trim((string) $child->textContent) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($child instanceof DOMElement && strtolower($child->nodeName) === 'div' && $this->isSimpleLineDiv($child)) {
|
||||
$run[] = $child;
|
||||
continue;
|
||||
}
|
||||
|
||||
$flush();
|
||||
}
|
||||
|
||||
$flush();
|
||||
}
|
||||
}
|
||||
|
||||
private function removeEmptyBlocks(DOMXPath $xpath): void
|
||||
{
|
||||
foreach ($xpath->query('//p|//div') ?: [] as $node) {
|
||||
if (! $node instanceof DOMElement || $node->parentNode === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($node->getElementsByTagName('*')->length > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$text = trim(str_replace("\xC2\xA0", ' ', $node->textContent));
|
||||
if ($text === '') {
|
||||
$node->parentNode->removeChild($node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function replaceWithPre(DOMDocument $document, DOMXPath $xpath, DOMElement $node): void
|
||||
{
|
||||
$hint = '';
|
||||
if (preg_match('/dp-([a-z0-9#+]+)/i', $node->getAttribute('class'), $match)) {
|
||||
$hint = $match[1];
|
||||
}
|
||||
|
||||
$lines = [];
|
||||
foreach ($xpath->query('.//li', $node) ?: [] as $item) {
|
||||
$lines[] = $this->plainText($document->saveHTML($item) ?: '');
|
||||
}
|
||||
|
||||
if ($lines === []) {
|
||||
foreach ($xpath->query('.//*[contains(@class, "codeHead")]', $node) ?: [] as $head) {
|
||||
$head->parentNode?->removeChild($head);
|
||||
}
|
||||
$lines = preg_split('/\R/u', $this->plainText($node->textContent)) ?: [];
|
||||
}
|
||||
|
||||
$code = implode("\n", $lines);
|
||||
$pre = $this->makePre($document, $code, $this->detectLanguage($hint, $code));
|
||||
$node->parentNode?->replaceChild($pre, $node);
|
||||
}
|
||||
|
||||
private function makePre(DOMDocument $document, string $code, string $language): DOMElement
|
||||
{
|
||||
$pre = $document->createElement('pre');
|
||||
$codeNode = $document->createElement('code');
|
||||
$codeNode->setAttribute('class', 'language-'.$language);
|
||||
$codeNode->appendChild($document->createTextNode($code));
|
||||
$pre->appendChild($codeNode);
|
||||
|
||||
return $pre;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $lines
|
||||
*/
|
||||
private function looksLikeCodeLines(array $lines): bool
|
||||
{
|
||||
$codeish = 0;
|
||||
foreach ($lines as $line) {
|
||||
if ($line === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/\\\\$/', $line)
|
||||
|| preg_match('/^\s*(docker|npm|npx|pnpm|yarn|git|curl|wget|sudo|ssh|kubectl|composer|php|python|pip|node)\b/i', $line)
|
||||
|| preg_match('/^\s+--[\w-]+/', $line)
|
||||
|| preg_match('/^\s*\|/', $line)
|
||||
|| preg_match('/[{};]$/', $line)
|
||||
) {
|
||||
$codeish++;
|
||||
}
|
||||
}
|
||||
|
||||
return $codeish >= 2;
|
||||
}
|
||||
|
||||
private function isSimpleLineDiv(DOMElement $div): bool
|
||||
{
|
||||
foreach ($div->childNodes as $child) {
|
||||
if ($child instanceof DOMElement && ! in_array(strtolower($child->nodeName), ['br', 'span', 'code', 'b', 'i', 'em', 'strong'], true)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$text = $this->plainText($div->textContent);
|
||||
|
||||
return $text !== '';
|
||||
}
|
||||
|
||||
private function detectLanguage(string $hint, string $code): string
|
||||
{
|
||||
if (preg_match('/^\s*(docker|kubectl|npm|npx|pnpm|yarn|composer|curl|wget|sudo|ssh)\b/m', $code)) {
|
||||
return 'bash';
|
||||
}
|
||||
|
||||
$aliases = $this->languageAliases();
|
||||
$key = strtolower(trim($hint));
|
||||
|
||||
return $aliases[$key] ?? ($key !== '' ? $key : 'text');
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover a docker/shell command that HtmlConverter flattened into one line.
|
||||
*/
|
||||
private function recoverFlattenedShell(string $content): string
|
||||
{
|
||||
if (str_contains($content, '<pre')) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
return preg_replace_callback(
|
||||
'/(?:docker|sudo docker)\s+run\b(?:[^<\n]*\\\\)+\s*[^<\n]+/i',
|
||||
function (array $match): string {
|
||||
$code = trim($this->plainText($match[0]));
|
||||
$code = preg_replace('/\\\\\s+/', "\\\n", $code) ?? $code;
|
||||
|
||||
return '<pre><code class="language-bash">'.e($code).'</code></pre>';
|
||||
},
|
||||
$content
|
||||
) ?? $content;
|
||||
}
|
||||
|
||||
private function closestTag(DOMNode $node, string $tag): ?DOMElement
|
||||
{
|
||||
$current = $node->parentNode;
|
||||
while ($current instanceof DOMElement) {
|
||||
if (strtolower($current->nodeName) === $tag) {
|
||||
return $current;
|
||||
}
|
||||
$current = $current->parentNode;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function loadDocument(string $html): DOMDocument
|
||||
{
|
||||
$document = new DOMDocument('1.0', 'UTF-8');
|
||||
$previous = libxml_use_internal_errors(true);
|
||||
$wrapped = '<div id="ll-root">'.$html.'</div>';
|
||||
$document->loadHTML('<?xml encoding="UTF-8">'.$wrapped, LIBXML_NOERROR | LIBXML_HTML_NODEFDTD);
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($previous);
|
||||
|
||||
return $document;
|
||||
}
|
||||
|
||||
private function innerHtml(DOMDocument $document): string
|
||||
{
|
||||
$root = $document->getElementById('ll-root');
|
||||
if (! $root instanceof DOMElement) {
|
||||
$root = (new DOMXPath($document))->query('//*[@id="ll-root"]')->item(0);
|
||||
}
|
||||
|
||||
if (! $root instanceof DOMElement) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $this->innerHtmlOf($root);
|
||||
}
|
||||
|
||||
private function innerHtmlOf(DOMElement $element): string
|
||||
{
|
||||
$html = '';
|
||||
foreach ($element->childNodes as $child) {
|
||||
$html .= $element->ownerDocument?->saveHTML($child) ?: '';
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Blog\Support\SablogHtmlNormalizer;
|
||||
use App\Models\Post;
|
||||
use Illuminate\Console\Command;
|
||||
use League\HTMLToMarkdown\HtmlConverter;
|
||||
@@ -36,6 +37,7 @@ class ConvertContent extends Command
|
||||
]);
|
||||
|
||||
$renderer = app(\App\Blog\Services\PostContentRenderer::class);
|
||||
$normalizer = app(SablogHtmlNormalizer::class);
|
||||
|
||||
$posts = $query->get();
|
||||
if ($posts->isEmpty()) {
|
||||
@@ -49,7 +51,7 @@ class ConvertContent extends Command
|
||||
|
||||
foreach ($posts as $post) {
|
||||
// [attach=xx] 是 markdown 保留字符,先换成令牌,渲染时再解析
|
||||
$markdown = $converter->convert($renderer->toMarkdownSafeTokens($post->content));
|
||||
$markdown = $normalizer->toMarkdown((string) $post->content, $converter, $renderer);
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->newLine();
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Blog\Support\SablogHtmlNormalizer;
|
||||
use App\Models\Post;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class FixImportedContent extends Command
|
||||
{
|
||||
protected $signature = 'sablog:fix-content
|
||||
{--all : Repair every post}
|
||||
{id? : Single post ID}
|
||||
{--dry-run : Preview only}';
|
||||
|
||||
protected $description = 'Clean leftover SaBlog HTML: literal , highlighter blocks, stacked docker divs';
|
||||
|
||||
public function handle(SablogHtmlNormalizer $normalizer): int
|
||||
{
|
||||
$query = Post::query();
|
||||
|
||||
if ($id = $this->argument('id')) {
|
||||
$query->where('id', $id);
|
||||
} elseif (! $this->option('all')) {
|
||||
$this->error('Pass a post ID or use --all');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$posts = $query->get();
|
||||
if ($posts->isEmpty()) {
|
||||
$this->info('No posts to repair');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$changed = 0;
|
||||
foreach ($posts as $post) {
|
||||
$cleaned = $normalizer->normalize((string) $post->content);
|
||||
if ($cleaned === $post->content) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->line("[{$post->id}] {$post->title}");
|
||||
continue;
|
||||
}
|
||||
|
||||
$post->content = $cleaned;
|
||||
$post->save();
|
||||
$changed++;
|
||||
}
|
||||
|
||||
$this->info($this->option('dry-run')
|
||||
? 'Preview only, nothing written'
|
||||
: "Repaired {$changed} posts");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -2,19 +2,18 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Blog\Services\AttachmentImporter;
|
||||
use App\Models\Category;
|
||||
use App\Models\Comment;
|
||||
use App\Models\Link;
|
||||
use App\Blog\Services\PostContentRenderer;
|
||||
use App\Blog\Support\SablogHtmlNormalizer;
|
||||
use App\Models\Post;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use App\Support\SlugGenerator;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use League\HTMLToMarkdown\HtmlConverter;
|
||||
use PDO;
|
||||
use Spatie\Tags\Tag;
|
||||
|
||||
@@ -271,6 +270,7 @@ class SablogImport extends Command
|
||||
'keywords' => $row['keywords'] ?: null,
|
||||
'status' => $status,
|
||||
'is_sticky' => (bool) $row['stick'],
|
||||
'sticky_level' => ((bool) $row['stick']) ? 1 : 0,
|
||||
'close_comment' => (bool) $row['closecomment'],
|
||||
'read_password' => $row['readpassword'] ?: null,
|
||||
'views' => (int) $row['views'],
|
||||
@@ -362,15 +362,16 @@ class SablogImport extends Command
|
||||
{
|
||||
$this->info('转换文章内容为 Markdown ...');
|
||||
|
||||
$converter = new \League\HTMLToMarkdown\HtmlConverter([
|
||||
$converter = new HtmlConverter([
|
||||
'strip_tags' => false,
|
||||
'hard_break' => true,
|
||||
]);
|
||||
$renderer = app(\App\Blog\Services\PostContentRenderer::class);
|
||||
$renderer = app(PostContentRenderer::class);
|
||||
$normalizer = app(SablogHtmlNormalizer::class);
|
||||
|
||||
$count = 0;
|
||||
foreach (Post::query()->where('content_format', 'html')->cursor() as $post) {
|
||||
$post->content = $converter->convert($renderer->toMarkdownSafeTokens($post->content));
|
||||
$post->content = $normalizer->toMarkdown((string) $post->content, $converter, $renderer);
|
||||
$post->content_format = 'markdown';
|
||||
$post->save();
|
||||
$count++;
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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([
|
||||
|
||||
@@ -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)。不要把 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)。不要把 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(),
|
||||
])
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
|
||||
+61
-7
@@ -2,25 +2,25 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\MediaDisk;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Xpier\FilamentMediaLibrary\Models\MediaLibrary;
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
use Spatie\Tags\HasTags;
|
||||
use Xpier\FilamentMediaLibrary\Models\MediaLibrary;
|
||||
|
||||
class Post extends Model implements HasMedia
|
||||
{
|
||||
use HasFactory;
|
||||
use InteractsWithMedia;
|
||||
use HasTags;
|
||||
use InteractsWithMedia;
|
||||
|
||||
protected $fillable = [
|
||||
'category_id',
|
||||
@@ -34,6 +34,7 @@ class Post extends Model implements HasMedia
|
||||
'cover_media_id',
|
||||
'status',
|
||||
'is_sticky',
|
||||
'sticky_level',
|
||||
'close_comment',
|
||||
'read_password',
|
||||
'views',
|
||||
@@ -45,10 +46,63 @@ class Post extends Model implements HasMedia
|
||||
protected $casts = [
|
||||
'meta' => 'array',
|
||||
'is_sticky' => 'boolean',
|
||||
'sticky_level' => 'integer',
|
||||
'close_comment' => 'boolean',
|
||||
'published_at' => 'datetime',
|
||||
];
|
||||
|
||||
public const STICKY_NONE = 0;
|
||||
|
||||
public const STICKY_LIGHT = 1;
|
||||
|
||||
public const STICKY_STRONG = 2;
|
||||
|
||||
public const STICKY_HEADLINE = 3;
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function stickyLevelOptions(): array
|
||||
{
|
||||
return [
|
||||
self::STICKY_NONE => '不置顶',
|
||||
self::STICKY_LIGHT => '轻置顶',
|
||||
self::STICKY_STRONG => '强置顶',
|
||||
self::STICKY_HEADLINE => '置顶头条',
|
||||
];
|
||||
}
|
||||
|
||||
public function stickyLabel(): ?string
|
||||
{
|
||||
$level = (int) $this->sticky_level;
|
||||
|
||||
return $level > 0 ? (self::stickyLevelOptions()[$level] ?? '置顶') : null;
|
||||
}
|
||||
|
||||
public function isPinned(): bool
|
||||
{
|
||||
return (int) $this->sticky_level > 0 || $this->is_sticky;
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (Post $post): void {
|
||||
$level = (int) ($post->sticky_level ?? 0);
|
||||
$post->sticky_level = max(self::STICKY_NONE, min(self::STICKY_HEADLINE, $level));
|
||||
|
||||
if ($post->is_sticky && $post->sticky_level < self::STICKY_LIGHT) {
|
||||
$post->sticky_level = self::STICKY_LIGHT;
|
||||
}
|
||||
|
||||
$post->is_sticky = $post->sticky_level > self::STICKY_NONE;
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeOrderedForList(Builder $query): Builder
|
||||
{
|
||||
return $query->orderByDesc('sticky_level')->latest('published_at');
|
||||
}
|
||||
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class);
|
||||
@@ -107,8 +161,8 @@ class Post extends Model implements HasMedia
|
||||
|
||||
public function registerMediaCollections(): void
|
||||
{
|
||||
$this->addMediaCollection('attachments')->useDisk(\App\Support\MediaDisk::name());
|
||||
$this->addMediaCollection('cover')->useDisk(\App\Support\MediaDisk::name())
|
||||
$this->addMediaCollection('attachments')->useDisk(MediaDisk::name());
|
||||
$this->addMediaCollection('cover')->useDisk(MediaDisk::name())
|
||||
->singleFile()
|
||||
->registerMediaConversions(function () {
|
||||
$this->addMediaConversion('card')->width(1200)->height(630)->sharpen(1);
|
||||
@@ -153,13 +207,13 @@ class Post extends Model implements HasMedia
|
||||
return '';
|
||||
}
|
||||
|
||||
$text = strip_tags($this->content);
|
||||
$text = (new \App\Blog\Support\SablogHtmlNormalizer)->plainText((string) $this->content);
|
||||
// 付费块不进入摘要,避免付费内容泄漏到列表页/SEO meta
|
||||
$text = preg_replace('/\[paid\].*?\[\/paid\]/s', '', $text) ?? $text;
|
||||
$text = preg_replace('/!\[([^\]]*)\]\((?:media|attach):\d+\)/i', '$1', $text) ?? $text;
|
||||
$text = preg_replace('/\[([^\]]*)\]\((?:media|attach):\d+\)/i', '$1', $text) ?? $text;
|
||||
$text = preg_replace('/\[[^\]]*\]/', '', $text) ?? $text;
|
||||
|
||||
return mb_substr($text, 0, 200);
|
||||
return mb_substr(trim($text), 0, 200);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,16 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Blog\Support\HomeViewMode;
|
||||
use App\Blog\Support\PageCacheMiddleware;
|
||||
use App\Blog\Support\PluginManager;
|
||||
use App\Models\MarketPackage;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\View\PanelsRenderHook;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Plugins\Neatstudio\Payment\Models\Payment;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -21,16 +27,16 @@ class AppServiceProvider extends ServiceProvider
|
||||
|
||||
private function registerMarketPurchaseListener(): void
|
||||
{
|
||||
$manager = app(\App\Blog\Support\PluginManager::class);
|
||||
$manager = app(PluginManager::class);
|
||||
|
||||
if (! $manager->isEnabled('neatstudio.payment')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$manager->addAction('payment.paid', function (\Plugins\Neatstudio\Payment\Models\Payment $payment) {
|
||||
$manager->addAction('payment.paid', function (Payment $payment) {
|
||||
$payable = $payment->payable;
|
||||
|
||||
if ($payable instanceof \App\Models\MarketPackage) {
|
||||
if ($payable instanceof MarketPackage) {
|
||||
$payable->markPaid($payment);
|
||||
}
|
||||
}, 10);
|
||||
@@ -54,7 +60,20 @@ class AppServiceProvider extends ServiceProvider
|
||||
$this->registerMarketPurchaseListener();
|
||||
|
||||
// 前台页面缓存(匿名 GET,缓存含 SEO 注入后的最终 HTML)
|
||||
app('router')->pushMiddlewareToGroup('web', \App\Blog\Support\PageCacheMiddleware::class);
|
||||
app('router')->pushMiddlewareToGroup('web', PageCacheMiddleware::class);
|
||||
|
||||
View::composer([
|
||||
'index',
|
||||
'list',
|
||||
'tag',
|
||||
'search',
|
||||
'partials.post-card',
|
||||
'partials.view-mode-switcher',
|
||||
], function ($view): void {
|
||||
if (! $view->offsetExists('homeViewMode')) {
|
||||
$view->with('homeViewMode', HomeViewMode::current());
|
||||
}
|
||||
});
|
||||
|
||||
// 后台紧凑化:侧边栏 + 表格行 + 筛选区(Filament 无官方全局紧凑开关,用 CSS 收紧)
|
||||
Filament::serving(function () {
|
||||
@@ -96,6 +115,22 @@ class AppServiceProvider extends ServiceProvider
|
||||
/* 文章编辑框加大(MarkdownEditor 基于 tiptap) */
|
||||
.fi-fo-markdown-editor .tiptap, .fi-fo-markdown-editor .ProseMirror { min-height: 26rem !important; }
|
||||
|
||||
/* 封面/媒体预览:禁止按栏宽 100% 拉伸,否则大图会把编辑页撑烂 */
|
||||
.ll-media-picker-preview img,
|
||||
img.h-32.max-w-full.rounded-md.object-contain {
|
||||
max-width: 220px !important;
|
||||
max-height: 124px !important;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
object-fit: contain !important;
|
||||
display: block !important;
|
||||
}
|
||||
.ll-media-picker-preview .relative,
|
||||
.relative.rounded-lg:has(> img.h-32.max-w-full) {
|
||||
width: fit-content;
|
||||
max-width: 15rem;
|
||||
}
|
||||
|
||||
/* 筛选条件区:整体一行横排,重置紧跟应用按钮,控件紧凑 */
|
||||
.fi-ta-filters-above-content-ctn { padding: 0.4rem 0.9rem !important; }
|
||||
.fi-ta-filters-heading { display: none !important; }
|
||||
|
||||
@@ -33,6 +33,18 @@ if (! function_exists('theme')) {
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('blog_paginate')) {
|
||||
/**
|
||||
* Front-end pager: fewer page chips so the sidebar column does not wrap under.
|
||||
*/
|
||||
function blog_paginate(\Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Eloquent\Relations\Relation $query, ?int $perPage = null): \Illuminate\Contracts\Pagination\LengthAwarePaginator
|
||||
{
|
||||
$perPage = max(1, (int) ($perPage ?? blog_setting('per_page', config('blog.per_page'))));
|
||||
|
||||
return $query->paginate($perPage)->onEachSide(1)->withQueryString();
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('blog_setting')) {
|
||||
/**
|
||||
* 读取博客设置(Setting 表,带缓存)。
|
||||
|
||||
Reference in New Issue
Block a user