M2: 主题系统(sablog经典+modern简约)+ 前台全部页面 + 老 URL 301 兼容 + markdown 双份导入
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Blog\Controllers;
|
||||
|
||||
use App\Models\Post;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class ArchiveController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$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')));
|
||||
|
||||
return theme_view('archives', compact('archives'));
|
||||
}
|
||||
|
||||
public function month(string $year, string $month)
|
||||
{
|
||||
$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')));
|
||||
|
||||
return theme_view('list', compact('posts'))
|
||||
->with('archiveTitle', "{$year} 年 {$month} 月");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Blog\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
public function showLogin()
|
||||
{
|
||||
if (auth()->check()) {
|
||||
return redirect()->route('home');
|
||||
}
|
||||
|
||||
return theme_view('login');
|
||||
}
|
||||
|
||||
public function login(Request $request)
|
||||
{
|
||||
$credentials = $request->validate([
|
||||
'email' => ['required', 'string'],
|
||||
'password' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
$field = filter_var($credentials['email'], FILTER_VALIDATE_EMAIL) ? 'email' : 'name';
|
||||
$credentials[$field] = $credentials['email'];
|
||||
unset($credentials['email']);
|
||||
|
||||
$user = User::query()->where($field, $credentials[$field])->first();
|
||||
|
||||
if ($user && $user->verifyPassword($request->password)) {
|
||||
Auth::login($user, $request->boolean('remember'));
|
||||
$user->forceFill([
|
||||
'logincount' => $user->logincount + 1,
|
||||
'loginip' => $request->ip(),
|
||||
'logintime' => now(),
|
||||
])->save();
|
||||
|
||||
return redirect()->intended(route('home'));
|
||||
}
|
||||
|
||||
return back()->withErrors(['email' => '账号或密码错误'])->withInput();
|
||||
}
|
||||
|
||||
public function logout(Request $request)
|
||||
{
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('home');
|
||||
}
|
||||
|
||||
public function showRegister()
|
||||
{
|
||||
if (auth()->check()) {
|
||||
return redirect()->route('home');
|
||||
}
|
||||
|
||||
return theme_view('register');
|
||||
}
|
||||
|
||||
public function register(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:50', 'unique:users,name'],
|
||||
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
|
||||
'password' => ['required', 'string', 'min:8', 'confirmed'],
|
||||
'url' => ['nullable', 'url', 'max:255'],
|
||||
]);
|
||||
|
||||
$user = User::create([
|
||||
'name' => $data['name'],
|
||||
'email' => $data['email'],
|
||||
'password' => Hash::make($data['password']),
|
||||
'url' => $data['url'] ?? null,
|
||||
'regip' => $request->ip(),
|
||||
]);
|
||||
$user->assignRole('member');
|
||||
|
||||
Auth::login($user);
|
||||
|
||||
return redirect()->route('profile');
|
||||
}
|
||||
|
||||
public function profile()
|
||||
{
|
||||
return theme_view('profile');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Blog\Controllers;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Post;
|
||||
|
||||
class CategoryController extends Controller
|
||||
{
|
||||
public function show(string $slug)
|
||||
{
|
||||
$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')));
|
||||
|
||||
return theme_view('list', compact('category', 'posts'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Blog\Controllers;
|
||||
|
||||
use App\Models\Comment;
|
||||
use App\Models\Post;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class CommentController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$comments = Comment::query()
|
||||
->where('status', 'published')
|
||||
->latest('created_at')
|
||||
->with('post:id,title,slug')
|
||||
->paginate(30);
|
||||
|
||||
return theme_view('comments', compact('comments'));
|
||||
}
|
||||
|
||||
public function store(Request $request, Post $post)
|
||||
{
|
||||
abort_unless($post->status === 'published', 404);
|
||||
|
||||
if ($post->close_comment) {
|
||||
return back()->with('error', '该文章已关闭评论');
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'author_name' => ['required', 'string', 'max:50'],
|
||||
'author_email' => ['nullable', 'email', 'max:255'],
|
||||
'author_url' => ['nullable', 'url', 'max:255'],
|
||||
'content' => ['required', 'string', 'min:'.(int) blog_setting('comment_min_len', 2), 'max:'.(int) blog_setting('comment_max_len', 6000)],
|
||||
]);
|
||||
|
||||
// 蜜罐:隐藏字段被填写则直接丢弃(现代垃圾评论防护)
|
||||
if ($request->filled('website')) {
|
||||
return back()->with('error', '提交失败,请重试');
|
||||
}
|
||||
|
||||
$minInterval = (int) blog_setting('comment_post_space', 20);
|
||||
if ($minInterval > 0) {
|
||||
$last = Comment::query()->where('ip', $request->ip())->latest('created_at')->first();
|
||||
if ($last && $last->created_at->diffInSeconds(now()) < $minInterval) {
|
||||
return back()->with('error', '评论太频繁,请稍后再试')->withInput();
|
||||
}
|
||||
}
|
||||
|
||||
$status = (int) blog_setting('comment_audit', 0) === 1 ? 'pending' : 'published';
|
||||
|
||||
$comment = DB::transaction(function () use ($post, $data, $request, $status) {
|
||||
$comment = $post->comments()->create([
|
||||
'user_id' => auth()->id(),
|
||||
'author_name' => $data['author_name'],
|
||||
'author_email' => $data['author_email'] ?? null,
|
||||
'author_url' => $data['author_url'] ?? null,
|
||||
'content' => $data['content'],
|
||||
'ip' => $request->ip(),
|
||||
'status' => $status,
|
||||
]);
|
||||
|
||||
if ($status === 'published') {
|
||||
$post->increment('comment_count');
|
||||
}
|
||||
|
||||
return $comment;
|
||||
});
|
||||
|
||||
if ($status === 'pending') {
|
||||
return back()->with('success', '评论已提交,等待审核通过后显示')->withFragment('comments');
|
||||
}
|
||||
|
||||
return back()->with('success', '评论已发布')->withFragment('comments');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace App\Blog\Controllers;
|
||||
|
||||
class Controller
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Blog\Controllers;
|
||||
|
||||
use App\Models\Post;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class FeedController extends Controller
|
||||
{
|
||||
public function rss()
|
||||
{
|
||||
$posts = Post::query()
|
||||
->published()
|
||||
->latest('published_at')
|
||||
->limit((int) blog_setting('rss_num', config('blog.rss_num')))
|
||||
->with('category', 'author', 'tags')
|
||||
->get();
|
||||
|
||||
$content = view('feed.rss', [
|
||||
'posts' => $posts,
|
||||
'siteName' => blog_setting('site_name', config('blog.name')),
|
||||
'siteDescription' => blog_setting('site_description', config('blog.description')),
|
||||
'xmlDeclaration' => '<?xml version="1.0" encoding="UTF-8"?>',
|
||||
])->render();
|
||||
|
||||
return response($content)->header('Content-Type', 'application/rss+xml; charset=utf-8');
|
||||
}
|
||||
|
||||
public function sitemap()
|
||||
{
|
||||
$posts = Post::query()
|
||||
->published()
|
||||
->latest('published_at')
|
||||
->get(['id', 'slug', 'updated_at']);
|
||||
|
||||
$content = view('feed.sitemap', [
|
||||
'posts' => $posts,
|
||||
'xmlDeclaration' => '<?xml version="1.0" encoding="UTF-8"?>',
|
||||
])->render();
|
||||
|
||||
return response($content)->header('Content-Type', 'application/xml; charset=utf-8');
|
||||
}
|
||||
|
||||
public function robots(): Response
|
||||
{
|
||||
$disallow = array_merge(config('robots.disallow'), (array) blog_setting('seo_robots_disallow', []));
|
||||
$sitemap = config('robots.sitemap');
|
||||
|
||||
$lines = ['User-agent: *'];
|
||||
foreach ($disallow as $path) {
|
||||
$lines[] = "Disallow: {$path}";
|
||||
}
|
||||
$lines[] = "Sitemap: ".url($sitemap);
|
||||
|
||||
return response(implode("\n", $lines)."\n")->header('Content-Type', 'text/plain; charset=utf-8');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Blog\Controllers;
|
||||
|
||||
use App\Models\Post;
|
||||
|
||||
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')));
|
||||
|
||||
return theme_view('index', compact('posts'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
namespace App\Blog\Controllers;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Comment;
|
||||
use App\Models\Post;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Str;
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
use Spatie\Tags\Tag;
|
||||
|
||||
/**
|
||||
* sablog 老 URL 兼容:全部 301 到新规范 URL,保证 SEO 不丢。
|
||||
*/
|
||||
class LegacyController extends Controller
|
||||
{
|
||||
/**
|
||||
* 兼容首页查询串式:/?action=xxx
|
||||
*/
|
||||
public function resolveAction(Request $request)
|
||||
{
|
||||
$action = $request->query('action');
|
||||
|
||||
return match ($action) {
|
||||
'show' => $this->post($request->integer('id')),
|
||||
'category' => $this->category($request->integer('cid')),
|
||||
'index' => $request->filled('setdate') ? $this->month($request->query('setdate')) : redirect()->route('home'),
|
||||
'tags', 'tag', 'tagslist' => $this->tagList($request),
|
||||
'search' => redirect()->route('search', ['q' => $request->query('keyword', $request->query('q', ''))]),
|
||||
'links' => redirect()->route('links'),
|
||||
'archives' => redirect()->route('archives'),
|
||||
'comments' => redirect()->route('comments'),
|
||||
'login' => redirect()->route('login'),
|
||||
'reg' => redirect()->route('register'),
|
||||
'finduser' => $this->findUser($request->integer('uid')),
|
||||
default => abort(404),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* /show-{id}-{page}.html
|
||||
*/
|
||||
public function showRewritten(string $id)
|
||||
{
|
||||
return $this->post((int) $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* /category-{cid}-{page}.html
|
||||
*/
|
||||
public function categoryRewritten(string $cid)
|
||||
{
|
||||
return $this->category((int) $cid);
|
||||
}
|
||||
|
||||
/**
|
||||
* /archives-{date}-{page}.html
|
||||
*/
|
||||
public function archivesRewritten(string $date)
|
||||
{
|
||||
return $this->month($date);
|
||||
}
|
||||
|
||||
/**
|
||||
* /tag/{name}
|
||||
*/
|
||||
public function tagByName(string $name)
|
||||
{
|
||||
$tag = Tag::findFromString($name, 'post');
|
||||
|
||||
return $tag
|
||||
? redirect()->route('tags.show', $tag->slug ?: $tag->name, 301)
|
||||
: abort(404);
|
||||
}
|
||||
|
||||
/**
|
||||
* attachment.php?id=N -> 媒体地址
|
||||
*/
|
||||
public function attachment(Request $request)
|
||||
{
|
||||
$id = $request->integer('id');
|
||||
$media = Media::query()
|
||||
->where('collection_name', 'attachments')
|
||||
->where('custom_properties->legacy_attachmentid', $id)
|
||||
->first();
|
||||
|
||||
if (! $media) {
|
||||
abort(404, '附件不存在');
|
||||
}
|
||||
|
||||
if ($media->getCustomProperty('pending_sync')) {
|
||||
abort(404, '附件尚未同步到存储');
|
||||
}
|
||||
|
||||
return redirect()->away($media->getUrl(), 301);
|
||||
}
|
||||
|
||||
/**
|
||||
* post.php 表单兼容:登录 / 注册 / 登出 / 评论 / 搜索
|
||||
*/
|
||||
public function postAction(Request $request)
|
||||
{
|
||||
return match ($request->input('action')) {
|
||||
'dologin' => app(AuthController::class)->login($request),
|
||||
'logout', 'clearcookies' => app(AuthController::class)->logout($request),
|
||||
'register' => app(AuthController::class)->register($request),
|
||||
'addcomment' => $this->addLegacyComment($request),
|
||||
'search' => redirect()->route('search', ['q' => $request->input('keyword', '')]),
|
||||
default => abort(404),
|
||||
};
|
||||
}
|
||||
|
||||
private function addLegacyComment(Request $request)
|
||||
{
|
||||
$post = Post::find((int) $request->input('articleid', $request->input('id')));
|
||||
|
||||
if (! $post || $post->status !== 'published') {
|
||||
return redirect()->route('home');
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'author' => ['nullable', 'string', 'max:50'],
|
||||
'email' => ['nullable', 'email', 'max:255'],
|
||||
'url' => ['nullable', 'url', 'max:255'],
|
||||
'content' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
$status = (int) Setting::get('comment_audit', 0) === 1 ? 'pending' : 'published';
|
||||
|
||||
$comment = $post->comments()->create([
|
||||
'user_id' => auth()->id(),
|
||||
'author_name' => $data['author'] ?? (auth()->user()->name ?? '匿名'),
|
||||
'author_email' => $data['email'] ?? null,
|
||||
'author_url' => $data['url'] ?? null,
|
||||
'content' => $data['content'],
|
||||
'ip' => $request->ip(),
|
||||
'status' => $status,
|
||||
]);
|
||||
|
||||
if ($status === 'published') {
|
||||
$post->increment('comment_count');
|
||||
}
|
||||
|
||||
return redirect()->route('posts.show', $post->slug ?: $post->id)
|
||||
->with($status === 'published' ? 'success' : 'error', $status === 'published' ? '评论已发布' : '评论已提交,等待审核')
|
||||
->withFragment('comments');
|
||||
}
|
||||
|
||||
private function post(int $id)
|
||||
{
|
||||
$post = Post::find($id);
|
||||
|
||||
return $post
|
||||
? redirect()->route('posts.show', $post->slug ?: $post->id, 301)
|
||||
: abort(404);
|
||||
}
|
||||
|
||||
private function category(int $cid)
|
||||
{
|
||||
$category = Category::find($cid);
|
||||
|
||||
return $category
|
||||
? redirect()->route('category.show', $category->slug ?: $category->id, 301)
|
||||
: abort(404);
|
||||
}
|
||||
|
||||
private function month(string $date)
|
||||
{
|
||||
if (! preg_match('/^(\d{4})(\d{2})?/', $date, $m)) {
|
||||
return redirect()->route('archives');
|
||||
}
|
||||
|
||||
$year = $m[1];
|
||||
$month = $m[2] ?? null;
|
||||
|
||||
return $month
|
||||
? redirect()->route('archives.month', [$year, $month], 301)
|
||||
: redirect()->route('archives.month', [$year, '01'], 301);
|
||||
}
|
||||
|
||||
private function tagList(Request $request)
|
||||
{
|
||||
if ($request->filled('id')) {
|
||||
// 老 sablog tagid 与新媒体 id 可能不一致,通过导入时记录的映射解析
|
||||
$legacyMap = json_decode((string) Setting::get('legacy_tags', '[]'), true) ?: [];
|
||||
$tagId = $legacyMap[(int) $request->query('id')] ?? null;
|
||||
$tag = $tagId ? Tag::find((int) $tagId) : Tag::find((int) $request->query('id'));
|
||||
|
||||
return $tag
|
||||
? redirect()->route('tags.show', $tag->slug ?: $tag->name, 301)
|
||||
: abort(404);
|
||||
}
|
||||
|
||||
return redirect()->route('tags', [], 301);
|
||||
}
|
||||
|
||||
private function findUser(int $uid)
|
||||
{
|
||||
if (auth()->check() && auth()->id() === $uid) {
|
||||
return redirect()->route('profile');
|
||||
}
|
||||
|
||||
$user = User::find($uid);
|
||||
|
||||
return $user
|
||||
? redirect()->route('profile', [], 301)
|
||||
: abort(404);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Blog\Controllers;
|
||||
|
||||
use App\Models\Link;
|
||||
use App\Models\Post;
|
||||
|
||||
class LinkController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$links = Link::query()->where('visible', true)->orderBy('display_order')->orderBy('name')->get();
|
||||
|
||||
return theme_view('links', compact('links'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Blog\Controllers;
|
||||
|
||||
use App\Blog\Services\PostContentRenderer;
|
||||
use App\Models\Post;
|
||||
|
||||
class PostController extends Controller
|
||||
{
|
||||
public function __construct(private PostContentRenderer $renderer)
|
||||
{
|
||||
}
|
||||
|
||||
public function show(string $slug)
|
||||
{
|
||||
$post = Post::query()
|
||||
->where('slug', $slug)
|
||||
->orWhere('id', (int) $slug)
|
||||
->first();
|
||||
|
||||
abort_unless($post && $post->status === 'published', 404);
|
||||
|
||||
$this->ensureReadable($post);
|
||||
|
||||
$post->increment('views');
|
||||
|
||||
$comments = $post->comments()
|
||||
->where('status', 'published')
|
||||
->latest('created_at')
|
||||
->paginate(20);
|
||||
|
||||
$contentHtml = $this->renderer->render($post);
|
||||
|
||||
return theme_view('show', compact('post', 'comments', 'contentHtml'));
|
||||
}
|
||||
|
||||
private function ensureReadable(Post $post): void
|
||||
{
|
||||
if (! $post->read_password) {
|
||||
return;
|
||||
}
|
||||
|
||||
$granted = session()->get("post_read_password.{$post->id}") === $post->read_password;
|
||||
$isEditor = auth()->check() && (auth()->user()->isAdmin() || auth()->user()->hasRole('editor'));
|
||||
|
||||
if (! $granted && ! $isEditor) {
|
||||
abort(403, '该文章需要访问密码');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Blog\Controllers;
|
||||
|
||||
use App\Models\Post;
|
||||
|
||||
class SearchController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$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')));
|
||||
|
||||
return theme_view('search', compact('posts', 'keyword'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Blog\Controllers;
|
||||
|
||||
use App\Models\Post;
|
||||
use Spatie\Tags\Tag;
|
||||
|
||||
class TagController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$counts = \Illuminate\Support\Facades\DB::table('taggables')
|
||||
->selectRaw('tag_id, COUNT(*) as total')
|
||||
->where('taggable_type', Post::class)
|
||||
->groupBy('tag_id')
|
||||
->pluck('total', 'tag_id');
|
||||
|
||||
$tags = Tag::query()
|
||||
->where('type', 'post')
|
||||
->get()
|
||||
->map(fn (Tag $tag) => $tag->setAttribute('posts_count', (int) ($counts[$tag->id] ?? 0)))
|
||||
->sortByDesc('posts_count')
|
||||
->values();
|
||||
|
||||
return theme_view('tags', compact('tags'));
|
||||
}
|
||||
|
||||
public function show(string $slug)
|
||||
{
|
||||
$tag = Tag::findFromString($slug, 'post');
|
||||
|
||||
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')));
|
||||
|
||||
return theme_view('tag', compact('tag', 'posts'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Blog\Controllers;
|
||||
|
||||
use App\Blog\Support\ThemeManager;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class ThemeAssetController extends Controller
|
||||
{
|
||||
public function show(ThemeManager $themes, string $theme, string $path)
|
||||
{
|
||||
if (! $themes->exists($theme)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$fullPath = $themes->path($theme).'/assets/'.$path;
|
||||
if (! File::isFile($fullPath)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return response(File::get($fullPath), 200, [
|
||||
'Content-Type' => $this->mimeType($fullPath),
|
||||
'Cache-Control' => 'public, max-age=86400',
|
||||
]);
|
||||
}
|
||||
|
||||
private function mimeType(string $path): string
|
||||
{
|
||||
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
|
||||
return match ($ext) {
|
||||
'css' => 'text/css',
|
||||
'js' => 'application/javascript',
|
||||
'png' => 'image/png',
|
||||
'jpg', 'jpeg' => 'image/jpeg',
|
||||
'gif' => 'image/gif',
|
||||
'svg' => 'image/svg+xml',
|
||||
'woff' => 'font/woff',
|
||||
'woff2' => 'font/woff2',
|
||||
'ico' => 'image/x-icon',
|
||||
default => 'application/octet-stream',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Blog\Providers;
|
||||
|
||||
use App\Blog\Support\ThemeManager;
|
||||
use App\Blog\View\Composers\SidebarComposer;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class ThemeServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(ThemeManager::class);
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
// 激活主题的 views 目录前置到全局视图路径,实现主题覆盖 + 默认兜底
|
||||
$this->prependActiveThemeViews();
|
||||
|
||||
// 侧边栏数据共享:所有前台视图自动获得 $categories/$recentPosts/$hotTags/$links 等
|
||||
View::composer(
|
||||
['index', 'show', 'list', 'archives', 'tags', 'tag', 'search', 'links', 'comments', 'login', 'register', 'profile'],
|
||||
SidebarComposer::class
|
||||
);
|
||||
}
|
||||
|
||||
private function prependActiveThemeViews(): void
|
||||
{
|
||||
$manager = app(ThemeManager::class);
|
||||
$views = $manager->path($manager->active()).'/views';
|
||||
|
||||
if (! is_dir($views)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$paths = config('view.paths');
|
||||
array_unshift($paths, $views);
|
||||
config(['view.paths' => $paths]);
|
||||
app('view')->getFinder()->setPaths($paths);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ class AttachmentImporter
|
||||
$properties = [
|
||||
'downloads' => (int) ($row['downloads'] ?? 0),
|
||||
'isimage' => (bool) ($row['isimage'] ?? false),
|
||||
'legacy_attachmentid' => (int) ($row['attachmentid'] ?? 0),
|
||||
'legacy_filepath' => $row['filepath'] ?? null,
|
||||
'legacy_thumb' => $row['thumb_filepath'] ?? null,
|
||||
'legacy_articleid' => (int) ($row['articleid'] ?? 0),
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Blog\Services;
|
||||
|
||||
use App\Models\Post;
|
||||
use League\CommonMark\Environment\Environment;
|
||||
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
|
||||
use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
|
||||
use League\CommonMark\MarkdownConverter;
|
||||
|
||||
class PostContentRenderer
|
||||
{
|
||||
private MarkdownConverter $converter;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$environment = new Environment([
|
||||
'html_input' => 'allow',
|
||||
'allow_unsafe_links' => true,
|
||||
'max_nesting_level' => 100,
|
||||
]);
|
||||
$environment->addExtension(new CommonMarkCoreExtension);
|
||||
$environment->addExtension(new GithubFlavoredMarkdownExtension);
|
||||
|
||||
$this->converter = new MarkdownConverter($environment);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将文章内容按格式渲染为 HTML。
|
||||
* markdown -> HTML;html 原样返回。两种格式都会解析附件令牌。
|
||||
*/
|
||||
public function render(Post $post): string
|
||||
{
|
||||
$content = $post->content_format === 'markdown'
|
||||
? $this->toHtml($post->content)
|
||||
: $post->content;
|
||||
|
||||
return $this->renderShortcodes($content, $post);
|
||||
}
|
||||
|
||||
public function toHtml(string $markdown): string
|
||||
{
|
||||
return $this->converter->convert($markdown)->getContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析附件引用。
|
||||
*
|
||||
* 兼容两种写法:
|
||||
* - 老 sablog HTML 内容: [attach=xx] / [img=xx]
|
||||
* - markdown 转换导入: {{attach:xx}} / {{img:xx}}([] 是 markdown 保留字符,转换时改为令牌)
|
||||
*
|
||||
* xx 是 sablog attachmentid;找不到时回退到按出现顺序的第 N 个附件。
|
||||
*/
|
||||
public function renderShortcodes(string $html, Post $post): string
|
||||
{
|
||||
$pattern = '/\[(attach|img)=(\d+)\]|\{\{(attach|img):(\d+)\}\}/';
|
||||
|
||||
if (! preg_match_all($pattern, $html, $matches, PREG_SET_ORDER)) {
|
||||
return $html;
|
||||
}
|
||||
|
||||
$mediaItems = $post->getMedia('attachments');
|
||||
$byLegacyId = $mediaItems->keyBy(fn ($m) => (int) $m->getCustomProperty('legacy_attachmentid'));
|
||||
$ordered = $mediaItems->values();
|
||||
|
||||
foreach ($matches as $match) {
|
||||
$full = $match[0];
|
||||
$type = $match[1] !== '' ? $match[1] : $match[3];
|
||||
$id = (int) ($match[2] !== '' ? $match[2] : $match[4]);
|
||||
|
||||
$media = $byLegacyId->get($id);
|
||||
|
||||
if (! $media) {
|
||||
// 兼容按索引引用的旧写法:第 $id 个附件(从 1 开始)
|
||||
$media = $ordered->get($id - 1);
|
||||
}
|
||||
|
||||
if (! $media) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$replacement = $type === 'img'
|
||||
? sprintf('<img src="%s" alt="%s" loading="lazy" />', $media->getUrl(), e($media->name))
|
||||
: sprintf('<a href="%s">%s</a>', $media->getUrl(), e($media->file_name));
|
||||
|
||||
$html = str_replace($full, $replacement, $html);
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把老 sablog HTML 里的 [attach=xx]/[img=xx] 换成 markdown 安全令牌,
|
||||
* 供 HTML -> markdown 转换前调用。
|
||||
*/
|
||||
public function toMarkdownSafeTokens(string $html): string
|
||||
{
|
||||
return preg_replace('/\[(attach|img)=(\d+)\]/', '{{$1:$2}}', $html);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Blog\Support;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class ThemeManager
|
||||
{
|
||||
public function path(?string $theme = null): string
|
||||
{
|
||||
return config('themes.path').($theme ? '/'.$theme : '');
|
||||
}
|
||||
|
||||
public function active(): string
|
||||
{
|
||||
$active = Setting::get('active_theme', config('themes.default'));
|
||||
|
||||
return $this->exists($active) ? $active : config('themes.default');
|
||||
}
|
||||
|
||||
public function exists(string $theme): bool
|
||||
{
|
||||
return is_dir($this->path($theme)) && File::exists($this->path($theme).'/theme.json');
|
||||
}
|
||||
|
||||
public function manifest(string $theme): array
|
||||
{
|
||||
$file = $this->path($theme).'/theme.json';
|
||||
if (! File::exists($file)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return json_decode(File::get($file), true) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描主题目录,返回所有主题 manifest。
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
$themes = [];
|
||||
|
||||
foreach (File::directories(config('themes.path')) as $dir) {
|
||||
$name = basename($dir);
|
||||
if (! File::exists($dir.'/theme.json')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$manifest = $this->manifest($name);
|
||||
$themes[$name] = [
|
||||
'name' => $name,
|
||||
'title' => $manifest['title'] ?? $name,
|
||||
'version' => $manifest['version'] ?? '0.0.0',
|
||||
'description' => $manifest['description'] ?? '',
|
||||
'author' => $manifest['author'] ?? '',
|
||||
'screenshot' => $manifest['screenshot'] ?? null,
|
||||
'active' => $name === $this->active(),
|
||||
];
|
||||
}
|
||||
|
||||
return $themes;
|
||||
}
|
||||
|
||||
public function activate(string $theme): void
|
||||
{
|
||||
if (! $this->exists($theme)) {
|
||||
throw new \InvalidArgumentException("主题不存在: {$theme}");
|
||||
}
|
||||
|
||||
Setting::set('active_theme', $theme);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析主题下的一个视图名。
|
||||
* 优先使用激活主题的视图,缺失时回退到内置兜底视图(blog-default)。
|
||||
*/
|
||||
public function view(string $view): string
|
||||
{
|
||||
$active = $this->active();
|
||||
|
||||
if (view()->exists("theme.{$active}.{$view}")) {
|
||||
return "theme.{$active}.{$view}";
|
||||
}
|
||||
|
||||
return "blog-default::{$view}";
|
||||
}
|
||||
|
||||
public function assetUrl(string $path, ?string $theme = null): string
|
||||
{
|
||||
$theme = $theme ?? $this->active();
|
||||
|
||||
return url('/themes/'.$theme.'/assets/'.ltrim($path, '/'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 主题变量(原 sablog stylevars 的现代化替代:主题级自定义配置)。
|
||||
*/
|
||||
public function variables(?string $theme = null): array
|
||||
{
|
||||
$theme = $theme ?? $this->active();
|
||||
|
||||
$vars = Setting::get('theme_vars_'.$theme, []);
|
||||
|
||||
return is_array($vars) ? $vars : (json_decode((string) $vars, true) ?: []);
|
||||
}
|
||||
|
||||
public function variable(string $key, mixed $default = null, ?string $theme = null): mixed
|
||||
{
|
||||
return $this->variables($theme)[$key] ?? $default;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Blog\View\Composers;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Link;
|
||||
use App\Models\Post;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\View\View;
|
||||
use Spatie\Tags\Tag;
|
||||
|
||||
class SidebarComposer
|
||||
{
|
||||
public function compose(View $view): void
|
||||
{
|
||||
$view->with('categories', Cache::remember('blog.sidebar.categories', now()->addHour(), function () {
|
||||
return Category::query()->orderBy('display_order')->orderBy('name')->get();
|
||||
}));
|
||||
|
||||
$view->with('recentPosts', Cache::remember('blog.sidebar.recent_posts', now()->addHour(), function () {
|
||||
return Post::published()->latest('published_at')->limit(10)->get(['id', 'title', 'slug']);
|
||||
}));
|
||||
|
||||
$view->with('recentComments', Cache::remember('blog.sidebar.recent_comments', now()->addHour(), function () {
|
||||
return \App\Models\Comment::query()
|
||||
->where('status', 'published')
|
||||
->latest('created_at')
|
||||
->limit(10)
|
||||
->with('post:id,title,slug')
|
||||
->get();
|
||||
}));
|
||||
|
||||
$view->with('hotTags', Cache::remember('blog.sidebar.hot_tags', now()->addHour(), function () {
|
||||
$counts = \Illuminate\Support\Facades\DB::table('taggables')
|
||||
->selectRaw('tag_id, COUNT(*) as total')
|
||||
->where('taggable_type', Post::class)
|
||||
->groupBy('tag_id')
|
||||
->pluck('total', 'tag_id');
|
||||
|
||||
return Tag::query()
|
||||
->where('type', 'post')
|
||||
->get()
|
||||
->map(fn (Tag $tag) => $tag->setAttribute('posts_count', (int) ($counts[$tag->id] ?? 0)))
|
||||
->sortByDesc('posts_count')
|
||||
->take(20)
|
||||
->values();
|
||||
}));
|
||||
|
||||
$view->with('links', Cache::remember('blog.sidebar.links', now()->addHour(), function () {
|
||||
return Link::query()->where('visible', true)->orderBy('display_order')->orderBy('name')->get();
|
||||
}));
|
||||
|
||||
$view->with('blogStats', Cache::remember('blog.sidebar.stats', now()->addHour(), function () {
|
||||
return [
|
||||
'posts' => Post::published()->count(),
|
||||
'comments' => \App\Models\Comment::query()->where('status', 'published')->count(),
|
||||
'categories' => Category::query()->count(),
|
||||
'tags' => Tag::query()->count(),
|
||||
];
|
||||
}));
|
||||
|
||||
$view->with('siteName', blog_setting('site_name', config('blog.name')));
|
||||
$view->with('siteDescription', blog_setting('site_description', config('blog.description')));
|
||||
$view->with('siteIcp', blog_setting('site_icp', config('blog.icp')));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user