M2: 主题系统(sablog经典+modern简约)+ 前台全部页面 + 老 URL 301 兼容 + markdown 双份导入

This commit is contained in:
ak
2026-08-11 17:58:29 +08:00
parent f16bb75538
commit 6214976a88
66 changed files with 2763 additions and 10 deletions
@@ -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}");
}
}
+93
View File
@@ -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');
}
}
+7
View File
@@ -0,0 +1,7 @@
<?php
namespace App\Blog\Controllers;
class Controller
{
}
+58
View File
@@ -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');
}
}
+19
View File
@@ -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'));
}
}
+215
View File
@@ -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);
}
}
+16
View File
@@ -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'));
}
}
+50
View File
@@ -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, '该文章需要访问密码');
}
}
}
+21
View File
@@ -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'));
}
}
+42
View File
@@ -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',
};
}
}