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 = [
|
$properties = [
|
||||||
'downloads' => (int) ($row['downloads'] ?? 0),
|
'downloads' => (int) ($row['downloads'] ?? 0),
|
||||||
'isimage' => (bool) ($row['isimage'] ?? false),
|
'isimage' => (bool) ($row['isimage'] ?? false),
|
||||||
|
'legacy_attachmentid' => (int) ($row['attachmentid'] ?? 0),
|
||||||
'legacy_filepath' => $row['filepath'] ?? null,
|
'legacy_filepath' => $row['filepath'] ?? null,
|
||||||
'legacy_thumb' => $row['thumb_filepath'] ?? null,
|
'legacy_thumb' => $row['thumb_filepath'] ?? null,
|
||||||
'legacy_articleid' => (int) ($row['articleid'] ?? 0),
|
'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')));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Post;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use League\HTMLToMarkdown\HtmlConverter;
|
||||||
|
|
||||||
|
class ConvertContent extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'content:convert
|
||||||
|
{--all : 转换全部 html 文章}
|
||||||
|
{id? : 单篇文章 ID}
|
||||||
|
{--dry-run : 只预览不写库}';
|
||||||
|
|
||||||
|
protected $description = '把文章内容从 HTML 转换为 Markdown(老 sablog 数据迁移用)';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$query = Post::query()->where('content_format', 'html');
|
||||||
|
|
||||||
|
if ($id = $this->argument('id')) {
|
||||||
|
$query->where('id', $id);
|
||||||
|
} elseif (! $this->option('all')) {
|
||||||
|
$this->error('请指定文章 ID 或使用 --all');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$converter = new HtmlConverter([
|
||||||
|
'strip_tags' => false,
|
||||||
|
'hard_break' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$renderer = app(\App\Blog\Services\PostContentRenderer::class);
|
||||||
|
|
||||||
|
$posts = $query->get();
|
||||||
|
if ($posts->isEmpty()) {
|
||||||
|
$this->info('没有需要转换的 HTML 文章');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bar = $this->output->createProgressBar($posts->count());
|
||||||
|
$bar->start();
|
||||||
|
|
||||||
|
foreach ($posts as $post) {
|
||||||
|
// [attach=xx] 是 markdown 保留字符,先换成令牌,渲染时再解析
|
||||||
|
$markdown = $converter->convert($renderer->toMarkdownSafeTokens($post->content));
|
||||||
|
|
||||||
|
if ($this->option('dry-run')) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->line("[{$post->id}] {$post->title}");
|
||||||
|
$this->line(mb_substr($markdown, 0, 200));
|
||||||
|
$bar->advance();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$post->content = $markdown;
|
||||||
|
$post->content_format = 'markdown';
|
||||||
|
$post->save();
|
||||||
|
|
||||||
|
$bar->advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
$bar->finish();
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('转换完成:'.($this->option('dry-run') ? '预览模式,未写库' : $posts->count().' 篇已转换为 Markdown'));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ class SablogImport extends Command
|
|||||||
{--prefix=sablog_ : 老表前缀}
|
{--prefix=sablog_ : 老表前缀}
|
||||||
{--attachments-dir= : 老 attachments 目录(用于同步文件,可选)}
|
{--attachments-dir= : 老 attachments 目录(用于同步文件,可选)}
|
||||||
{--sync-attachments : 同步附件文件到新存储}
|
{--sync-attachments : 同步附件文件到新存储}
|
||||||
|
{--convert-markdown : 导入后把 HTML 内容转为 Markdown([attach=xx] 转成 {{attach:xx}} 令牌,渲染时解析)}
|
||||||
{--fresh : 清空目标表后重新导入}';
|
{--fresh : 清空目标表后重新导入}';
|
||||||
|
|
||||||
protected $description = '从 SaBlog-X 老库脚本迁移数据到 laralog';
|
protected $description = '从 SaBlog-X 老库脚本迁移数据到 laralog';
|
||||||
@@ -63,6 +64,10 @@ class SablogImport extends Command
|
|||||||
$this->importLinks();
|
$this->importLinks();
|
||||||
$this->importAttachments();
|
$this->importAttachments();
|
||||||
|
|
||||||
|
if ($this->option('convert-markdown')) {
|
||||||
|
$this->convertToMarkdown();
|
||||||
|
}
|
||||||
|
|
||||||
$this->syncCounters();
|
$this->syncCounters();
|
||||||
|
|
||||||
$this->newLine();
|
$this->newLine();
|
||||||
@@ -157,7 +162,7 @@ class SablogImport extends Command
|
|||||||
$count = 0;
|
$count = 0;
|
||||||
foreach ($this->rows('categories') as $row) {
|
foreach ($this->rows('categories') as $row) {
|
||||||
$slug = SlugGenerator::make($row['name'], 'categories', 'slug', ignoreId: (int) $row['cid'], fallback: 'category-'.$row['cid']);
|
$slug = SlugGenerator::make($row['name'], 'categories', 'slug', ignoreId: (int) $row['cid'], fallback: 'category-'.$row['cid']);
|
||||||
Category::query()->updateOrCreate(['id' => $row['cid']], [
|
DB::table('categories')->updateOrInsert(['id' => (int) $row['cid']], [
|
||||||
'name' => $row['name'],
|
'name' => $row['name'],
|
||||||
'slug' => $slug,
|
'slug' => $slug,
|
||||||
'display_order' => (int) $row['displayorder'],
|
'display_order' => (int) $row['displayorder'],
|
||||||
@@ -182,7 +187,7 @@ class SablogImport extends Command
|
|||||||
$count = 0;
|
$count = 0;
|
||||||
foreach ($this->rows('users') as $row) {
|
foreach ($this->rows('users') as $row) {
|
||||||
$email = $this->legacyEmail($row['username'], $count);
|
$email = $this->legacyEmail($row['username'], $count);
|
||||||
$user = User::query()->updateOrCreate(['id' => $row['userid']], [
|
DB::table('users')->updateOrInsert(['id' => (int) $row['userid']], [
|
||||||
'name' => $row['username'],
|
'name' => $row['username'],
|
||||||
'email' => $email,
|
'email' => $email,
|
||||||
'password' => null,
|
'password' => null,
|
||||||
@@ -197,8 +202,9 @@ class SablogImport extends Command
|
|||||||
'updated_at' => now(),
|
'updated_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$user = User::find((int) $row['userid']);
|
||||||
$role = $groupMap[(int) $row['groupid']] ?? 'member';
|
$role = $groupMap[(int) $row['groupid']] ?? 'member';
|
||||||
if (! $user->hasRole($role)) {
|
if ($user && ! $user->hasRole($role)) {
|
||||||
$user->assignRole($role);
|
$user->assignRole($role);
|
||||||
}
|
}
|
||||||
$count++;
|
$count++;
|
||||||
@@ -222,13 +228,14 @@ class SablogImport extends Command
|
|||||||
$status = (int) $row['visible'] === 1 ? 'published' : 'draft';
|
$status = (int) $row['visible'] === 1 ? 'published' : 'draft';
|
||||||
$dateline = date('Y-m-d H:i:s', (int) $row['dateline']);
|
$dateline = date('Y-m-d H:i:s', (int) $row['dateline']);
|
||||||
|
|
||||||
Post::query()->updateOrCreate(['id' => $row['articleid']], [
|
DB::table('posts')->updateOrInsert(['id' => (int) $row['articleid']], [
|
||||||
'category_id' => (int) $row['cid'] ?: null,
|
'category_id' => (int) $row['cid'] ?: null,
|
||||||
'user_id' => (int) $row['uid'] ?: null,
|
'user_id' => (int) $row['uid'] ?: null,
|
||||||
'title' => $row['title'],
|
'title' => $row['title'],
|
||||||
'slug' => $slug,
|
'slug' => $slug,
|
||||||
'excerpt' => $row['description'] ?: null,
|
'excerpt' => $row['description'] ?: null,
|
||||||
'content' => $row['content'],
|
'content' => $row['content'],
|
||||||
|
'content_format' => 'html',
|
||||||
'keywords' => $row['keywords'] ?: null,
|
'keywords' => $row['keywords'] ?: null,
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
'is_sticky' => (bool) $row['stick'],
|
'is_sticky' => (bool) $row['stick'],
|
||||||
@@ -248,6 +255,7 @@ class SablogImport extends Command
|
|||||||
|
|
||||||
private function importTags(): void
|
private function importTags(): void
|
||||||
{
|
{
|
||||||
|
$legacyMap = [];
|
||||||
$count = 0;
|
$count = 0;
|
||||||
foreach ($this->rows('tags') as $row) {
|
foreach ($this->rows('tags') as $row) {
|
||||||
$tagName = trim($row['tag']);
|
$tagName = trim($row['tag']);
|
||||||
@@ -262,6 +270,8 @@ class SablogImport extends Command
|
|||||||
$tag->save();
|
$tag->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$legacyMap[(int) $row['tagid']] = $tag->id;
|
||||||
|
|
||||||
foreach (explode(',', (string) $row['aids']) as $aid) {
|
foreach (explode(',', (string) $row['aids']) as $aid) {
|
||||||
$post = Post::find((int) trim($aid));
|
$post = Post::find((int) trim($aid));
|
||||||
if ($post && ! $post->tags()->where('tags.id', $tag->id)->exists()) {
|
if ($post && ! $post->tags()->where('tags.id', $tag->id)->exists()) {
|
||||||
@@ -271,6 +281,10 @@ class SablogImport extends Command
|
|||||||
$count++;
|
$count++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($legacyMap) {
|
||||||
|
Setting::set('legacy_tags', $legacyMap);
|
||||||
|
}
|
||||||
|
|
||||||
$this->report['tags'] = $count;
|
$this->report['tags'] = $count;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +292,7 @@ class SablogImport extends Command
|
|||||||
{
|
{
|
||||||
$count = 0;
|
$count = 0;
|
||||||
foreach ($this->rows('comments') as $row) {
|
foreach ($this->rows('comments') as $row) {
|
||||||
Comment::query()->updateOrCreate(['id' => $row['commentid']], [
|
DB::table('comments')->updateOrInsert(['id' => (int) $row['commentid']], [
|
||||||
'post_id' => (int) $row['articleid'],
|
'post_id' => (int) $row['articleid'],
|
||||||
'author_name' => $row['author'] ?: '匿名',
|
'author_name' => $row['author'] ?: '匿名',
|
||||||
'author_url' => $row['url'] ?: null,
|
'author_url' => $row['url'] ?: null,
|
||||||
@@ -297,7 +311,7 @@ class SablogImport extends Command
|
|||||||
{
|
{
|
||||||
$count = 0;
|
$count = 0;
|
||||||
foreach ($this->rows('links') as $row) {
|
foreach ($this->rows('links') as $row) {
|
||||||
Link::query()->updateOrCreate(['id' => $row['linkid']], [
|
DB::table('links')->updateOrInsert(['id' => (int) $row['linkid']], [
|
||||||
'name' => $row['name'],
|
'name' => $row['name'],
|
||||||
'url' => $row['url'],
|
'url' => $row['url'],
|
||||||
'note' => $row['note'] ?: null,
|
'note' => $row['note'] ?: null,
|
||||||
@@ -312,6 +326,27 @@ class SablogImport extends Command
|
|||||||
$this->report['links'] = $count;
|
$this->report['links'] = $count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function convertToMarkdown(): void
|
||||||
|
{
|
||||||
|
$this->info('转换文章内容为 Markdown ...');
|
||||||
|
|
||||||
|
$converter = new \League\HTMLToMarkdown\HtmlConverter([
|
||||||
|
'strip_tags' => false,
|
||||||
|
'hard_break' => true,
|
||||||
|
]);
|
||||||
|
$renderer = app(\App\Blog\Services\PostContentRenderer::class);
|
||||||
|
|
||||||
|
$count = 0;
|
||||||
|
foreach (Post::query()->where('content_format', 'html')->cursor() as $post) {
|
||||||
|
$post->content = $converter->convert($renderer->toMarkdownSafeTokens($post->content));
|
||||||
|
$post->content_format = 'markdown';
|
||||||
|
$post->save();
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->report['markdown_converted'] = $count;
|
||||||
|
}
|
||||||
|
|
||||||
private function importAttachments(): void
|
private function importAttachments(): void
|
||||||
{
|
{
|
||||||
if (! $this->db->query('SHOW TABLES LIKE "'.$this->prefix.'attachments"')->fetchColumn()) {
|
if (! $this->db->query('SHOW TABLES LIKE "'.$this->prefix.'attachments"')->fetchColumn()) {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class Comment extends Model
|
|||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'ai_review' => 'array',
|
'ai_review' => 'array',
|
||||||
|
'created_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
public $timestamps = false;
|
public $timestamps = false;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class Post extends Model implements HasMedia
|
|||||||
'slug',
|
'slug',
|
||||||
'excerpt',
|
'excerpt',
|
||||||
'content',
|
'content',
|
||||||
|
'content_format',
|
||||||
'keywords',
|
'keywords',
|
||||||
'status',
|
'status',
|
||||||
'is_sticky',
|
'is_sticky',
|
||||||
@@ -82,6 +83,11 @@ class Post extends Model implements HasMedia
|
|||||||
public function registerMediaCollections(): void
|
public function registerMediaCollections(): void
|
||||||
{
|
{
|
||||||
$this->addMediaCollection('attachments')->useDisk(\App\Support\MediaDisk::name());
|
$this->addMediaCollection('attachments')->useDisk(\App\Support\MediaDisk::name());
|
||||||
|
$this->addMediaCollection('cover')->useDisk(\App\Support\MediaDisk::name())
|
||||||
|
->singleFile()
|
||||||
|
->registerMediaConversions(function () {
|
||||||
|
$this->addMediaConversion('card')->width(1200)->height(630)->sharpen(1);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function registerMediaConversions(?Media $media = null): void
|
public function registerMediaConversions(?Media $media = null): void
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Blog\Support\ThemeManager;
|
||||||
|
|
||||||
|
if (! function_exists('theme_view')) {
|
||||||
|
/**
|
||||||
|
* 渲染主题视图:激活主题目录已前置到全局视图路径,主题缺失时自动回退默认视图。
|
||||||
|
*/
|
||||||
|
function theme_view(string $view, array $data = []): \Illuminate\Contracts\View\View
|
||||||
|
{
|
||||||
|
return view($view, $data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('theme')) {
|
||||||
|
/**
|
||||||
|
* 主题辅助函数:theme() / theme('asset', 'style.css') / theme('var', 'key', default)
|
||||||
|
*/
|
||||||
|
function theme(?string $method = null, ...$args): mixed
|
||||||
|
{
|
||||||
|
$manager = app(ThemeManager::class);
|
||||||
|
|
||||||
|
return match ($method) {
|
||||||
|
'asset' => $manager->assetUrl($args[0] ?? ''),
|
||||||
|
'var' => $manager->variable($args[0] ?? '', $args[1] ?? null),
|
||||||
|
'vars' => $manager->variables(),
|
||||||
|
'name' => $manager->active(),
|
||||||
|
default => $manager,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('blog_setting')) {
|
||||||
|
/**
|
||||||
|
* 读取博客设置(Setting 表,带缓存)。
|
||||||
|
*/
|
||||||
|
function blog_setting(string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
return \App\Models\Setting::get($key, $default);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,4 +3,5 @@
|
|||||||
return [
|
return [
|
||||||
App\Providers\AppServiceProvider::class,
|
App\Providers\AppServiceProvider::class,
|
||||||
App\Providers\Filament\AdminPanelProvider::class,
|
App\Providers\Filament\AdminPanelProvider::class,
|
||||||
|
App\Blog\Providers\ThemeServiceProvider::class,
|
||||||
];
|
];
|
||||||
|
|||||||
+6
-1
@@ -10,6 +10,8 @@
|
|||||||
"filament/filament": "^5.7",
|
"filament/filament": "^5.7",
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
"laravel/tinker": "^2.10.1",
|
"laravel/tinker": "^2.10.1",
|
||||||
|
"league/commonmark": "^2.9",
|
||||||
|
"league/html-to-markdown": "^5.1",
|
||||||
"spatie/laravel-backup": "^9.0",
|
"spatie/laravel-backup": "^9.0",
|
||||||
"spatie/laravel-medialibrary": "^11.0",
|
"spatie/laravel-medialibrary": "^11.0",
|
||||||
"spatie/laravel-permission": "^6.0",
|
"spatie/laravel-permission": "^6.0",
|
||||||
@@ -33,7 +35,10 @@
|
|||||||
"App\\": "app/",
|
"App\\": "app/",
|
||||||
"Database\\Factories\\": "database/factories/",
|
"Database\\Factories\\": "database/factories/",
|
||||||
"Database\\Seeders\\": "database/seeders/"
|
"Database\\Seeders\\": "database/seeders/"
|
||||||
}
|
},
|
||||||
|
"files": [
|
||||||
|
"app/Support/helpers.php"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"autoload-dev": {
|
"autoload-dev": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
|
|||||||
Generated
+90
-1
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "3a470c618c6761bd46250fdc42d222de",
|
"content-hash": "c0357e1996000075eb3879929f19e248",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "anourvalar/eloquent-serialize",
|
"name": "anourvalar/eloquent-serialize",
|
||||||
@@ -2987,6 +2987,95 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-01-23T15:30:45+00:00"
|
"time": "2026-01-23T15:30:45+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "league/html-to-markdown",
|
||||||
|
"version": "5.1.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/thephpleague/html-to-markdown.git",
|
||||||
|
"reference": "0b4066eede55c48f38bcee4fb8f0aa85654390fd"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/thephpleague/html-to-markdown/zipball/0b4066eede55c48f38bcee4fb8f0aa85654390fd",
|
||||||
|
"reference": "0b4066eede55c48f38bcee4fb8f0aa85654390fd",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-dom": "*",
|
||||||
|
"ext-xml": "*",
|
||||||
|
"php": "^7.2.5 || ^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"mikehaertl/php-shellcommand": "^1.1.0",
|
||||||
|
"phpstan/phpstan": "^1.8.8",
|
||||||
|
"phpunit/phpunit": "^8.5 || ^9.2",
|
||||||
|
"scrutinizer/ocular": "^1.6",
|
||||||
|
"unleashedtech/php-coding-standard": "^2.7 || ^3.0",
|
||||||
|
"vimeo/psalm": "^4.22 || ^5.0"
|
||||||
|
},
|
||||||
|
"bin": [
|
||||||
|
"bin/html-to-markdown"
|
||||||
|
],
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "5.2-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"League\\HTMLToMarkdown\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Colin O'Dell",
|
||||||
|
"email": "colinodell@gmail.com",
|
||||||
|
"homepage": "https://www.colinodell.com",
|
||||||
|
"role": "Lead Developer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Nick Cernis",
|
||||||
|
"email": "nick@cern.is",
|
||||||
|
"homepage": "http://modernnerd.net",
|
||||||
|
"role": "Original Author"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "An HTML-to-markdown conversion helper for PHP",
|
||||||
|
"homepage": "https://github.com/thephpleague/html-to-markdown",
|
||||||
|
"keywords": [
|
||||||
|
"html",
|
||||||
|
"markdown"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/thephpleague/html-to-markdown/issues",
|
||||||
|
"source": "https://github.com/thephpleague/html-to-markdown/tree/5.1.1"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://www.colinodell.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://www.paypal.me/colinpodell/10.00",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/colinodell",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/league/html-to-markdown",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2023-07-12T21:21:09+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "league/mime-type-detection",
|
"name": "league/mime-type-detection",
|
||||||
"version": "1.17.0",
|
"version": "1.17.0",
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('posts', function (Blueprint $table) {
|
||||||
|
$table->string('content_format', 10)->default('markdown')->after('content');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('posts', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('content_format');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
/* ============ LaraLog 默认主题 ============ */
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #333;
|
||||||
|
background: #f5f6f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: #2d6cdf; text-decoration: none; }
|
||||||
|
a:hover { color: #1a4fa8; }
|
||||||
|
|
||||||
|
.container { max-width: 1100px; margin: 0 auto; padding: 0 16px; }
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.site-header { background: #fff; border-bottom: 1px solid #e5e7eb; padding: 18px 0; }
|
||||||
|
.site-header .container { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; }
|
||||||
|
.site-title { font-size: 26px; font-weight: 800; color: #111; }
|
||||||
|
.site-desc { font-size: 13px; color: #888; margin-top: 2px; }
|
||||||
|
.site-nav { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; font-size: 15px; }
|
||||||
|
.site-nav a.active { color: #2d6cdf; font-weight: 600; }
|
||||||
|
.site-search input { padding: 6px 10px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; width: 150px; }
|
||||||
|
.inline-form { display: inline; }
|
||||||
|
.link-btn { background: none; border: none; color: #2d6cdf; cursor: pointer; font-size: 15px; padding: 0; }
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
.main-layout { display: grid; grid-template-columns: 1fr 320px; gap: 24px; padding: 24px 16px; align-items: start; }
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.main-layout { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content */
|
||||||
|
.content { min-width: 0; }
|
||||||
|
|
||||||
|
.post-card, .post-full, .widget, .auth-box, .profile-box, .links-list {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card.sticky { border-left: 4px solid #f59e0b; }
|
||||||
|
|
||||||
|
.post-title { font-size: 20px; line-height: 1.4; margin-bottom: 8px; }
|
||||||
|
.post-card .post-title a { color: #111; }
|
||||||
|
.post-card .post-title a:hover { color: #2d6cdf; }
|
||||||
|
.badge { background: #f59e0b; color: #fff; font-size: 12px; padding: 2px 6px; border-radius: 4px; margin-right: 6px; vertical-align: middle; }
|
||||||
|
|
||||||
|
.post-meta { font-size: 13px; color: #888; margin-bottom: 10px; }
|
||||||
|
.post-excerpt { color: #555; margin-bottom: 10px; }
|
||||||
|
.post-footer { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; font-size: 14px; }
|
||||||
|
.read-more { font-weight: 600; }
|
||||||
|
|
||||||
|
.post-full .post-title { font-size: 28px; }
|
||||||
|
.post-cover { margin: -20px -24px 16px; border-radius: 10px 10px 0 0; overflow: hidden; }
|
||||||
|
.post-cover img { width: 100%; height: auto; display: block; max-height: 420px; object-fit: cover; }
|
||||||
|
.post-card .post-cover { margin: -20px -24px 12px; border-radius: 10px 10px 0 0; }
|
||||||
|
.post-card .post-cover img { max-height: 260px; }
|
||||||
|
.post-content { margin-top: 16px; font-size: 16.5px; }
|
||||||
|
.post-content img { max-width: 100%; height: auto; border-radius: 6px; }
|
||||||
|
.post-content pre { background: #1e1e2e; color: #e6e6e6; padding: 14px; border-radius: 8px; overflow-x: auto; margin: 12px 0; }
|
||||||
|
.post-content code { background: #f0f1f3; padding: 2px 5px; border-radius: 4px; font-size: 14px; }
|
||||||
|
.post-content pre code { background: none; padding: 0; }
|
||||||
|
.post-content blockquote { border-left: 4px solid #2d6cdf; padding-left: 14px; color: #666; margin: 12px 0; }
|
||||||
|
.post-content h1, .post-content h2, .post-content h3 { margin: 18px 0 8px; line-height: 1.3; }
|
||||||
|
.post-content ul, .post-content ol { padding-left: 24px; margin: 8px 0; }
|
||||||
|
.post-content table { border-collapse: collapse; width: 100%; margin: 12px 0; }
|
||||||
|
.post-content th, .post-content td { border: 1px solid #ddd; padding: 6px 10px; }
|
||||||
|
|
||||||
|
.post-tags { margin-top: 16px; }
|
||||||
|
.tag-link { display: inline-block; background: #eef2ff; color: #3b5bdb; border-radius: 4px; padding: 2px 8px; font-size: 13px; margin: 2px 4px 2px 0; }
|
||||||
|
.tag-link:hover { background: #dbe4ff; }
|
||||||
|
|
||||||
|
/* Comments */
|
||||||
|
.comments-section { background: #fff; border: 1px solid #e5e7eb; border-radius: 10px; padding: 20px 24px; margin-bottom: 20px; }
|
||||||
|
.section-title { font-size: 18px; margin-bottom: 14px; }
|
||||||
|
.comment { padding: 12px 0; border-bottom: 1px solid #f0f1f3; }
|
||||||
|
.comment:last-of-type { border-bottom: none; }
|
||||||
|
.comment-head { font-size: 14px; margin-bottom: 4px; }
|
||||||
|
.comment-time { color: #999; }
|
||||||
|
.comment-body { font-size: 15px; color: #444; }
|
||||||
|
|
||||||
|
.comment-form { margin-top: 16px; }
|
||||||
|
.form-row { margin-bottom: 12px; }
|
||||||
|
.form-row label { display: block; font-size: 14px; margin-bottom: 4px; }
|
||||||
|
.form-row input, .form-row textarea {
|
||||||
|
width: 100%; padding: 8px 12px; border: 1px solid #ddd; border-radius: 6px; font-size: 15px; font-family: inherit;
|
||||||
|
}
|
||||||
|
.honeypot { position: absolute; left: -9999px; opacity: 0; }
|
||||||
|
.error { color: #e03131; font-size: 13px; margin-top: 4px; }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
background: #2d6cdf; color: #fff; border: none; padding: 9px 20px; border-radius: 6px;
|
||||||
|
font-size: 15px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn:hover { background: #1a4fa8; }
|
||||||
|
.btn-primary { background: #2d6cdf; }
|
||||||
|
|
||||||
|
.alert { padding: 10px 16px; border-radius: 8px; margin-bottom: 14px; font-size: 14px; }
|
||||||
|
.alert-success { background: #d3f9d8; color: #2b8a3e; }
|
||||||
|
.alert-error { background: #ffe3e3; color: #c92a2a; }
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
.sidebar { position: sticky; top: 20px; }
|
||||||
|
.widget { padding: 16px 20px; }
|
||||||
|
.widget-title { font-size: 15px; margin-bottom: 10px; padding-bottom: 8px; border-bottom: 2px solid #2d6cdf; }
|
||||||
|
.widget-list { list-style: none; }
|
||||||
|
.widget-list li { font-size: 14px; padding: 4px 0; }
|
||||||
|
.count { color: #aaa; font-size: 13px; }
|
||||||
|
.tag-cloud { line-height: 2; }
|
||||||
|
.tag-cloud.big { line-height: 2.4; }
|
||||||
|
|
||||||
|
/* Misc */
|
||||||
|
.empty { color: #999; text-align: center; padding: 30px 0; }
|
||||||
|
.list-title { font-size: 22px; margin-bottom: 16px; }
|
||||||
|
.archive-year { font-size: 17px; margin: 14px 0 6px; }
|
||||||
|
.archive-list { list-style: none; }
|
||||||
|
.archive-posts { margin: 4px 0 10px 22px; }
|
||||||
|
.archive-posts time { color: #aaa; font-size: 13px; }
|
||||||
|
|
||||||
|
.pagination { margin: 16px 0; }
|
||||||
|
.pagination-links { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||||
|
.pagination-links a, .pagination-links .current, .pagination-links .disabled {
|
||||||
|
padding: 6px 12px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; background: #fff;
|
||||||
|
}
|
||||||
|
.pagination-links .current { background: #2d6cdf; color: #fff; border-color: #2d6cdf; }
|
||||||
|
.pagination-links .disabled { color: #bbb; }
|
||||||
|
|
||||||
|
.auth-layout { display: flex; justify-content: center; padding: 40px 16px; }
|
||||||
|
.auth-box { width: 100%; max-width: 420px; }
|
||||||
|
.auth-alt { margin-top: 14px; font-size: 14px; color: #666; text-align: center; }
|
||||||
|
|
||||||
|
.links-list { list-style: none; }
|
||||||
|
.links-list li { padding: 6px 0; }
|
||||||
|
.link-note { color: #999; font-size: 14px; }
|
||||||
|
|
||||||
|
.site-footer { background: #fff; border-top: 1px solid #e5e7eb; padding: 20px 0; margin-top: 24px; text-align: center; font-size: 13px; color: #888; }
|
||||||
|
.powered { margin-top: 4px; }
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
@php $pageTitle = '归档 - '.$siteName; @endphp
|
||||||
|
@include('partials.head')
|
||||||
|
<body>
|
||||||
|
@include('partials.header')
|
||||||
|
<div class="container main-layout">
|
||||||
|
<main class="content">
|
||||||
|
<h1 class="list-title">文章归档</h1>
|
||||||
|
|
||||||
|
@forelse($archives as $year => $months)
|
||||||
|
<h2 class="archive-year">{{ $year }} 年</h2>
|
||||||
|
<ul class="archive-list">
|
||||||
|
@foreach($months as $month => $monthPosts)
|
||||||
|
<li>
|
||||||
|
<a href="{{ route('archives.month', [$year, $month]) }}">{{ (int) $month }} 月</a>
|
||||||
|
<span class="count">({{ $monthPosts->count() }} 篇)</span>
|
||||||
|
<ul class="archive-posts">
|
||||||
|
@foreach($monthPosts as $post)
|
||||||
|
<li><a href="{{ route('posts.show', $post->slug ?: $post->id) }}">{{ $post->title }}</a> <time>{{ $post->published_at->format('m-d') }}</time></li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
@empty
|
||||||
|
<div class="empty">暂无归档</div>
|
||||||
|
@endforelse
|
||||||
|
</main>
|
||||||
|
@include('partials.sidebar')
|
||||||
|
</div>
|
||||||
|
@include('partials.footer')
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
@php $pageTitle = '最新评论 - '.$siteName; @endphp
|
||||||
|
@include('partials.head')
|
||||||
|
<body>
|
||||||
|
@include('partials.header')
|
||||||
|
<div class="container main-layout">
|
||||||
|
<main class="content">
|
||||||
|
<h1 class="list-title">最新评论</h1>
|
||||||
|
|
||||||
|
@forelse($comments as $comment)
|
||||||
|
<div class="comment" id="comment-{{ $comment->id }}">
|
||||||
|
<div class="comment-head">
|
||||||
|
<strong>{{ $comment->author_name }}</strong>
|
||||||
|
<span>· 评论于 <a href="{{ route('posts.show', $comment->post->slug ?: $comment->post->id) }}#comment-{{ $comment->id }}">{{ $comment->post->title }}</a></span>
|
||||||
|
<span class="comment-time">· {{ $comment->created_at->format('Y-m-d H:i') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="comment-body">{!! nl2br(e($comment->content)) !!}</div>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<div class="empty">暂无评论</div>
|
||||||
|
@endforelse
|
||||||
|
|
||||||
|
{{ $comments->links('partials.pagination-links') }}
|
||||||
|
</main>
|
||||||
|
@include('partials.sidebar')
|
||||||
|
</div>
|
||||||
|
@include('partials.footer')
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{!! $xmlDeclaration !!}
|
||||||
|
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
|
||||||
|
<channel>
|
||||||
|
<title>{{ $siteName }}</title>
|
||||||
|
<link>{{ url('/') }}</link>
|
||||||
|
<description>{{ $siteDescription }}</description>
|
||||||
|
<language>zh-CN</language>
|
||||||
|
<atom:link href="{{ route('feed.rss') }}" rel="self" type="application/rss+xml"/>
|
||||||
|
<lastBuildDate>{{ now()->toRssString() }}</lastBuildDate>
|
||||||
|
@foreach($posts as $post)
|
||||||
|
<item>
|
||||||
|
<title>{{ $post->title }}</title>
|
||||||
|
<link>{{ route('posts.show', $post->slug ?: $post->id) }}</link>
|
||||||
|
<guid isPermaLink="true">{{ route('posts.show', $post->slug ?: $post->id) }}</guid>
|
||||||
|
<pubDate>{{ $post->published_at->toRssString() }}</pubDate>
|
||||||
|
<description>{{ Str::limit(strip_tags($post->excerpt_or_fallback), 300) }}</description>
|
||||||
|
@if($post->category)
|
||||||
|
<category>{{ $post->category->name }}</category>
|
||||||
|
@endif
|
||||||
|
@foreach($post->tags as $tag)
|
||||||
|
<category>{{ $tag->name }}</category>
|
||||||
|
@endforeach
|
||||||
|
@if($post->author)
|
||||||
|
<author>{{ $post->author->email }} ({{ $post->author->name }})</author>
|
||||||
|
@endif
|
||||||
|
<content:encoded><![CDATA[
|
||||||
|
{!! app(\App\Blog\Services\PostContentRenderer::class)->render($post) !!}
|
||||||
|
]]></content:encoded>
|
||||||
|
</item>
|
||||||
|
@endforeach
|
||||||
|
</channel>
|
||||||
|
</rss>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{!! $xmlDeclaration !!}
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<url>
|
||||||
|
<loc>{{ route('home') }}</loc>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>{{ route('archives') }}</loc>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.6</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>{{ route('tags') }}</loc>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.4</priority>
|
||||||
|
</url>
|
||||||
|
@foreach($posts as $post)
|
||||||
|
<url>
|
||||||
|
<loc>{{ route('posts.show', $post->slug ?: $post->id) }}</loc>
|
||||||
|
<lastmod>{{ $post->updated_at?->toAtomString() }}</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
@endforeach
|
||||||
|
</urlset>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
@include('partials.head')
|
||||||
|
<body>
|
||||||
|
@include('partials.header')
|
||||||
|
<div class="container main-layout">
|
||||||
|
<main class="content">
|
||||||
|
@if(session('success'))
|
||||||
|
<div class="alert alert-success">{{ session('success') }}</div>
|
||||||
|
@endif
|
||||||
|
@if(session('error'))
|
||||||
|
<div class="alert alert-error">{{ session('error') }}</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@forelse($posts as $post)
|
||||||
|
@include('partials.post-card', ['post' => $post])
|
||||||
|
@empty
|
||||||
|
<div class="empty">暂无文章</div>
|
||||||
|
@endforelse
|
||||||
|
|
||||||
|
@include('partials.pagination', ['paginator' => $posts])
|
||||||
|
</main>
|
||||||
|
@include('partials.sidebar')
|
||||||
|
</div>
|
||||||
|
@include('partials.footer')
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
@php $pageTitle = '友情链接 - '.$siteName; @endphp
|
||||||
|
@include('partials.head')
|
||||||
|
<body>
|
||||||
|
@include('partials.header')
|
||||||
|
<div class="container main-layout">
|
||||||
|
<main class="content">
|
||||||
|
<h1 class="list-title">友情链接</h1>
|
||||||
|
<ul class="links-list">
|
||||||
|
@forelse($links as $link)
|
||||||
|
<li>
|
||||||
|
<a href="{{ $link->url }}" target="_blank" rel="noopener nofollow">{{ $link->name }}</a>
|
||||||
|
@if($link->note)<span class="link-note">— {{ $link->note }}</span>@endif
|
||||||
|
</li>
|
||||||
|
@empty
|
||||||
|
<div class="empty">暂无链接</div>
|
||||||
|
@endforelse
|
||||||
|
</ul>
|
||||||
|
</main>
|
||||||
|
@include('partials.sidebar')
|
||||||
|
</div>
|
||||||
|
@include('partials.footer')
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
@php
|
||||||
|
$pageTitle = ($archiveTitle ?? $category->name ?? '文章列表').' - '.$siteName;
|
||||||
|
@endphp
|
||||||
|
@include('partials.head')
|
||||||
|
<body>
|
||||||
|
@include('partials.header')
|
||||||
|
<div class="container main-layout">
|
||||||
|
<main class="content">
|
||||||
|
<h1 class="list-title">{{ $archiveTitle ?? ($category->name ?? '文章列表') }}</h1>
|
||||||
|
|
||||||
|
@forelse($posts as $post)
|
||||||
|
@include('partials.post-card', ['post' => $post])
|
||||||
|
@empty
|
||||||
|
<div class="empty">该分类暂无文章</div>
|
||||||
|
@endforelse
|
||||||
|
|
||||||
|
@include('partials.pagination', ['paginator' => $posts])
|
||||||
|
</main>
|
||||||
|
@include('partials.sidebar')
|
||||||
|
</div>
|
||||||
|
@include('partials.footer')
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
@php $pageTitle = '登录 - '.$siteName; @endphp
|
||||||
|
@include('partials.head')
|
||||||
|
<body>
|
||||||
|
@include('partials.header')
|
||||||
|
<div class="container auth-layout">
|
||||||
|
<main class="auth-box">
|
||||||
|
<h1 class="list-title">登录</h1>
|
||||||
|
<form method="post" action="{{ route('login') }}">
|
||||||
|
@csrf
|
||||||
|
<div class="form-row">
|
||||||
|
<label>账号(邮箱或用户名)</label>
|
||||||
|
<input type="text" name="email" required value="{{ old('email') }}" autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>密码</label>
|
||||||
|
<input type="password" name="password" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label><input type="checkbox" name="remember"> 记住我</label>
|
||||||
|
</div>
|
||||||
|
@error('email')<p class="error">{{ $message }}</p>@enderror
|
||||||
|
<button type="submit" class="btn btn-primary">登录</button>
|
||||||
|
</form>
|
||||||
|
<p class="auth-alt">还没有账号?<a href="{{ route('register') }}">立即注册</a></p>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
@include('partials.footer')
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<footer class="site-footer">
|
||||||
|
<div class="container">
|
||||||
|
<p>
|
||||||
|
© {{ date('Y') }} {{ $siteName }}
|
||||||
|
@if($siteIcp)
|
||||||
|
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">{{ $siteIcp }}</a>
|
||||||
|
@endif
|
||||||
|
</p>
|
||||||
|
<p class="powered">Powered by <a href="https://laravel.com" target="_blank" rel="noopener">Laravel</a> & <a href="https://filamentphp.com" target="_blank" rel="noopener">Filament</a></p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<title>{{ $pageTitle ?? $siteName }}</title>
|
||||||
|
<meta name="description" content="{{ $pageDescription ?? $siteDescription }}">
|
||||||
|
@if(($pageKeywords ?? '') !== '')
|
||||||
|
<meta name="keywords" content="{{ $pageKeywords }}">
|
||||||
|
@endif
|
||||||
|
<meta name="generator" content="LaraLog">
|
||||||
|
<link rel="canonical" href="{{ url()->current() }}">
|
||||||
|
<link rel="alternate" type="application/rss+xml" title="{{ $siteName }}" href="{{ route('feed.rss') }}">
|
||||||
|
<link rel="stylesheet" href="{{ asset('css/blog.css') }}">
|
||||||
|
@stack('head')
|
||||||
|
</head>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<header class="site-header">
|
||||||
|
<div class="container">
|
||||||
|
<div class="site-brand">
|
||||||
|
<a href="{{ route('home') }}" class="site-title">{{ $siteName }}</a>
|
||||||
|
@if($siteDescription)
|
||||||
|
<p class="site-desc">{{ $siteDescription }}</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<nav class="site-nav">
|
||||||
|
<a href="{{ route('home') }}" class="{{ request()->routeIs('home') ? 'active' : '' }}">首页</a>
|
||||||
|
<a href="{{ route('archives') }}" class="{{ request()->routeIs('archives*') ? 'active' : '' }}">归档</a>
|
||||||
|
<a href="{{ route('tags') }}" class="{{ request()->routeIs('tags*') ? 'active' : '' }}">标签</a>
|
||||||
|
<a href="{{ route('links') }}" class="{{ request()->routeIs('links') ? 'active' : '' }}">友链</a>
|
||||||
|
<a href="{{ route('comments') }}" class="{{ request()->routeIs('comments') ? 'active' : '' }}">评论</a>
|
||||||
|
<form class="site-search" action="{{ route('search') }}" method="get">
|
||||||
|
<input type="search" name="q" placeholder="搜索..." value="{{ request('q', request('keyword', '')) }}">
|
||||||
|
</form>
|
||||||
|
@auth
|
||||||
|
<a href="{{ route('profile') }}">{{ auth()->user()->name }}</a>
|
||||||
|
<form action="{{ route('logout') }}" method="post" class="inline-form">
|
||||||
|
@csrf
|
||||||
|
<button type="submit" class="link-btn">退出</button>
|
||||||
|
</form>
|
||||||
|
@else
|
||||||
|
<a href="{{ route('login') }}">登录</a>
|
||||||
|
<a href="{{ route('register') }}">注册</a>
|
||||||
|
@endauth
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
@if ($paginator->hasPages())
|
||||||
|
<nav class="pagination-links" role="navigation">
|
||||||
|
{{-- Previous --}}
|
||||||
|
@if ($paginator->onFirstPage())
|
||||||
|
<span class="disabled">« 上一页</span>
|
||||||
|
@else
|
||||||
|
<a href="{{ $paginator->previousPageUrl() }}" rel="prev">« 上一页</a>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Numbers --}}
|
||||||
|
@foreach ($elements as $element)
|
||||||
|
@if (is_string($element))
|
||||||
|
<span class="disabled">{{ $element }}</span>
|
||||||
|
@endif
|
||||||
|
@if (is_array($element))
|
||||||
|
@foreach ($element as $page => $url)
|
||||||
|
@if ($page == $paginator->currentPage())
|
||||||
|
<span class="current">{{ $page }}</span>
|
||||||
|
@else
|
||||||
|
<a href="{{ $url }}">{{ $page }}</a>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
|
||||||
|
{{-- Next --}}
|
||||||
|
@if ($paginator->hasMorePages())
|
||||||
|
<a href="{{ $paginator->nextPageUrl() }}" rel="next">下一页 »</a>
|
||||||
|
@else
|
||||||
|
<span class="disabled">下一页 »</span>
|
||||||
|
@endif
|
||||||
|
</nav>
|
||||||
|
@endif
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
@if ($paginator->hasPages())
|
||||||
|
<nav class="pagination">
|
||||||
|
{{ $paginator->links('partials.pagination-links') }}
|
||||||
|
</nav>
|
||||||
|
@endif
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<article class="post-card {{ $post->is_sticky ? 'sticky' : '' }}">
|
||||||
|
@if($cover = $post->getFirstMedia('cover'))
|
||||||
|
<a href="{{ route('posts.show', $post->slug ?: $post->id) }}" class="post-cover">
|
||||||
|
<img src="{{ $cover->getUrl('card') ?: $cover->getUrl() }}" alt="{{ $post->title }}" loading="lazy">
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
<h2 class="post-title">
|
||||||
|
@if($post->is_sticky)<span class="badge">置顶</span>@endif
|
||||||
|
<a href="{{ route('posts.show', $post->slug ?: $post->id) }}">{{ $post->title }}</a>
|
||||||
|
</h2>
|
||||||
|
<div class="post-meta">
|
||||||
|
<time datetime="{{ $post->published_at->toIso8601String() }}">{{ $post->published_at->format('Y-m-d H:i') }}</time>
|
||||||
|
@if($post->category)
|
||||||
|
<span>·</span>
|
||||||
|
<a href="{{ route('category.show', $post->category->slug ?: $post->category->id) }}">{{ $post->category->name }}</a>
|
||||||
|
@endif
|
||||||
|
<span>· 阅读 {{ $post->views }}</span>
|
||||||
|
<span>· 评论 {{ $post->comment_count }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="post-excerpt">
|
||||||
|
{{ Str::limit(strip_tags($post->excerpt_or_fallback), 200) }}
|
||||||
|
</div>
|
||||||
|
<div class="post-footer">
|
||||||
|
<a href="{{ route('posts.show', $post->slug ?: $post->id) }}" class="read-more">阅读全文 »</a>
|
||||||
|
@foreach($post->tags as $tag)
|
||||||
|
<a href="{{ route('tags.show', $tag->slug ?: $tag->name) }}" class="tag-link">#{{ $tag->name }}</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<aside class="sidebar">
|
||||||
|
<section class="widget">
|
||||||
|
<h3 class="widget-title">分类</h3>
|
||||||
|
<ul class="widget-list">
|
||||||
|
@forelse($categories as $category)
|
||||||
|
<li>
|
||||||
|
<a href="{{ route('category.show', $category->slug ?: $category->id) }}">{{ $category->name }}</a>
|
||||||
|
<span class="count">({{ $category->post_count }})</span>
|
||||||
|
</li>
|
||||||
|
@empty
|
||||||
|
<li>暂无分类</li>
|
||||||
|
@endforelse
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="widget">
|
||||||
|
<h3 class="widget-title">最新文章</h3>
|
||||||
|
<ul class="widget-list">
|
||||||
|
@forelse($recentPosts as $post)
|
||||||
|
<li><a href="{{ route('posts.show', $post->slug ?: $post->id) }}">{{ $post->title }}</a></li>
|
||||||
|
@empty
|
||||||
|
<li>暂无文章</li>
|
||||||
|
@endforelse
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="widget">
|
||||||
|
<h3 class="widget-title">最新评论</h3>
|
||||||
|
<ul class="widget-list">
|
||||||
|
@forelse($recentComments as $comment)
|
||||||
|
<li>
|
||||||
|
<a href="{{ route('posts.show', $comment->post->slug ?: $comment->post->id) }}#comment-{{ $comment->id }}">
|
||||||
|
{{ $comment->author_name }}:{{ Str::limit(strip_tags($comment->content), 30) }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
@empty
|
||||||
|
<li>暂无评论</li>
|
||||||
|
@endforelse
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="widget">
|
||||||
|
<h3 class="widget-title">标签云</h3>
|
||||||
|
<div class="tag-cloud">
|
||||||
|
@forelse($hotTags as $tag)
|
||||||
|
<a href="{{ route('tags.show', $tag->slug ?: $tag->name) }}" class="tag-link">{{ $tag->name }}</a>
|
||||||
|
@empty
|
||||||
|
<span>暂无标签</span>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="widget">
|
||||||
|
<h3 class="widget-title">友情链接</h3>
|
||||||
|
<ul class="widget-list">
|
||||||
|
@forelse($links as $link)
|
||||||
|
<li><a href="{{ $link->url }}" target="_blank" rel="noopener">{{ $link->name }}</a></li>
|
||||||
|
@empty
|
||||||
|
<li>暂无链接</li>
|
||||||
|
@endforelse
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="widget">
|
||||||
|
<h3 class="widget-title">站点统计</h3>
|
||||||
|
<ul class="widget-list">
|
||||||
|
<li>文章:{{ $blogStats['posts'] }} 篇</li>
|
||||||
|
<li>评论:{{ $blogStats['comments'] }} 条</li>
|
||||||
|
<li>分类:{{ $blogStats['categories'] }} 个</li>
|
||||||
|
<li>标签:{{ $blogStats['tags'] }} 个</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
@php $pageTitle = '个人中心 - '.$siteName; $user = auth()->user(); @endphp
|
||||||
|
@include('partials.head')
|
||||||
|
<body>
|
||||||
|
@include('partials.header')
|
||||||
|
<div class="container main-layout">
|
||||||
|
<main class="content">
|
||||||
|
<h1 class="list-title">个人中心</h1>
|
||||||
|
<div class="profile-box">
|
||||||
|
<p><strong>昵称:</strong>{{ $user->name }}</p>
|
||||||
|
<p><strong>邮箱:</strong>{{ $user->email }}</p>
|
||||||
|
@if($user->url)<p><strong>主页:</strong><a href="{{ $user->url }}" target="_blank" rel="noopener nofollow">{{ $user->url }}</a></p>@endif
|
||||||
|
<p><strong>注册时间:</strong>{{ $user->created_at?->format('Y-m-d') }}</p>
|
||||||
|
<p><strong>登录次数:</strong>{{ $user->logincount }}</p>
|
||||||
|
<p><strong>角色:</strong>{{ $user->getRoleNames()->implode(', ') ?: '会员' }}</p>
|
||||||
|
<p><strong>我的文章:</strong>{{ $user->posts()->count() }} 篇</p>
|
||||||
|
<p><strong>我的评论:</strong>{{ $user->comments()->count() }} 条</p>
|
||||||
|
</div>
|
||||||
|
<p><a href="{{ route('home') }}">« 返回首页</a></p>
|
||||||
|
</main>
|
||||||
|
@include('partials.sidebar')
|
||||||
|
</div>
|
||||||
|
@include('partials.footer')
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
@php $pageTitle = '注册 - '.$siteName; @endphp
|
||||||
|
@include('partials.head')
|
||||||
|
<body>
|
||||||
|
@include('partials.header')
|
||||||
|
<div class="container auth-layout">
|
||||||
|
<main class="auth-box">
|
||||||
|
<h1 class="list-title">注册</h1>
|
||||||
|
<form method="post" action="{{ route('register') }}">
|
||||||
|
@csrf
|
||||||
|
<div class="form-row">
|
||||||
|
<label>昵称</label>
|
||||||
|
<input type="text" name="name" required value="{{ old('name') }}">
|
||||||
|
@error('name')<p class="error">{{ $message }}</p>@enderror
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>邮箱</label>
|
||||||
|
<input type="email" name="email" required value="{{ old('email') }}">
|
||||||
|
@error('email')<p class="error">{{ $message }}</p>@enderror
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>密码(至少 8 位)</label>
|
||||||
|
<input type="password" name="password" required>
|
||||||
|
@error('password')<p class="error">{{ $message }}</p>@enderror
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>确认密码</label>
|
||||||
|
<input type="password" name="password_confirmation" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>个人主页(可选)</label>
|
||||||
|
<input type="url" name="url" value="{{ old('url') }}">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">注册</button>
|
||||||
|
</form>
|
||||||
|
<p class="auth-alt">已有账号?<a href="{{ route('login') }}">去登录</a></p>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
@include('partials.footer')
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
@php $pageTitle = '搜索 - '.$siteName; @endphp
|
||||||
|
@include('partials.head')
|
||||||
|
<body>
|
||||||
|
@include('partials.header')
|
||||||
|
<div class="container main-layout">
|
||||||
|
<main class="content">
|
||||||
|
<h1 class="list-title">搜索:{{ $keyword ?: '全部' }}</h1>
|
||||||
|
<form action="{{ route('search') }}" method="get" class="search-form">
|
||||||
|
<input type="search" name="q" value="{{ $keyword }}" placeholder="输入关键词搜索文章">
|
||||||
|
<button type="submit" class="btn">搜索</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
@forelse($posts as $post)
|
||||||
|
@include('partials.post-card', ['post' => $post])
|
||||||
|
@empty
|
||||||
|
<div class="empty">没有找到相关文章</div>
|
||||||
|
@endforelse
|
||||||
|
|
||||||
|
@include('partials.pagination', ['paginator' => $posts])
|
||||||
|
</main>
|
||||||
|
@include('partials.sidebar')
|
||||||
|
</div>
|
||||||
|
@include('partials.footer')
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
@php
|
||||||
|
$pageTitle = $post->title.' - '.$siteName;
|
||||||
|
$pageDescription = Str::limit(strip_tags($post->excerpt_or_fallback), 150);
|
||||||
|
$pageKeywords = $post->keywords ?: ($post->tags->pluck('name')->implode(','));
|
||||||
|
@endphp
|
||||||
|
@include('partials.head')
|
||||||
|
<body>
|
||||||
|
@include('partials.header')
|
||||||
|
<div class="container main-layout">
|
||||||
|
<main class="content">
|
||||||
|
@if(session('success'))
|
||||||
|
<div class="alert alert-success">{{ session('success') }}</div>
|
||||||
|
@endif
|
||||||
|
@if(session('error'))
|
||||||
|
<div class="alert alert-error">{{ session('error') }}</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<article class="post-full">
|
||||||
|
@if($cover = $post->getFirstMedia('cover'))
|
||||||
|
<div class="post-cover">
|
||||||
|
<img src="{{ $cover->getUrl('card') ?: $cover->getUrl() }}" alt="{{ $post->title }}">
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
<h1 class="post-title">{{ $post->title }}</h1>
|
||||||
|
<div class="post-meta">
|
||||||
|
<time datetime="{{ $post->published_at->toIso8601String() }}">{{ $post->published_at->format('Y-m-d H:i') }}</time>
|
||||||
|
@if($post->author)<span>· {{ $post->author->name }}</span>@endif
|
||||||
|
@if($post->category)
|
||||||
|
<span>· <a href="{{ route('category.show', $post->category->slug ?: $post->category->id) }}">{{ $post->category->name }}</a></span>
|
||||||
|
@endif
|
||||||
|
<span>· 阅读 {{ $post->views }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="post-content">
|
||||||
|
{!! $contentHtml !!}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($post->tags->isNotEmpty())
|
||||||
|
<div class="post-tags">
|
||||||
|
@foreach($post->tags as $tag)
|
||||||
|
<a href="{{ route('tags.show', $tag->slug ?: $tag->name) }}" class="tag-link">#{{ $tag->name }}</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</article>
|
||||||
|
|
||||||
|
@if(! $post->close_comment)
|
||||||
|
<section id="comments" class="comments-section">
|
||||||
|
<h2 class="section-title">评论 ({{ $comments->total() }})</h2>
|
||||||
|
|
||||||
|
@forelse($comments as $comment)
|
||||||
|
<div class="comment" id="comment-{{ $comment->id }}">
|
||||||
|
<div class="comment-head">
|
||||||
|
<strong>{{ $comment->author_name }}</strong>
|
||||||
|
@if($comment->author_url)
|
||||||
|
<span>· <a href="{{ $comment->author_url }}" target="_blank" rel="noopener nofollow">访问主页</a></span>
|
||||||
|
@endif
|
||||||
|
<span class="comment-time">· {{ $comment->created_at->format('Y-m-d H:i') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="comment-body">{!! nl2br(e($comment->content)) !!}</div>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<p class="empty">暂无评论</p>
|
||||||
|
@endforelse
|
||||||
|
|
||||||
|
{{ $comments->links('partials.pagination-links') }}
|
||||||
|
|
||||||
|
<form method="post" action="{{ route('comments.store', $post) }}" class="comment-form" id="comment-form">
|
||||||
|
@csrf
|
||||||
|
<div class="form-row">
|
||||||
|
@auth
|
||||||
|
<input type="hidden" name="author_name" value="{{ auth()->user()->name }}">
|
||||||
|
@else
|
||||||
|
<input type="text" name="author_name" placeholder="昵称 *" required value="{{ old('author_name') }}">
|
||||||
|
<input type="email" name="author_email" placeholder="邮箱(不公开)" value="{{ old('author_email') }}">
|
||||||
|
<input type="url" name="author_url" placeholder="网站(可选)" value="{{ old('author_url') }}">
|
||||||
|
@endauth
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<textarea name="content" rows="5" placeholder="写下你的评论..." required minlength="{{ blog_setting('comment_min_len', 2) }}">{{ old('content') }}</textarea>
|
||||||
|
</div>
|
||||||
|
{{-- 蜜罐字段:人类不会填写 --}}
|
||||||
|
<div class="honeypot"><label>网站 <input type="text" name="website" tabindex="-1" autocomplete="off"></label></div>
|
||||||
|
@error('content')<p class="error">{{ $message }}</p>@enderror
|
||||||
|
@if($errors->has('author_name'))<p class="error">{{ $errors->first('author_name') }}</p>@endif
|
||||||
|
<button type="submit" class="btn">发表评论</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
@endif
|
||||||
|
</main>
|
||||||
|
@include('partials.sidebar')
|
||||||
|
</div>
|
||||||
|
@include('partials.footer')
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
@php $pageTitle = '标签:'.$tag->name.' - '.$siteName; @endphp
|
||||||
|
@include('partials.head')
|
||||||
|
<body>
|
||||||
|
@include('partials.header')
|
||||||
|
<div class="container main-layout">
|
||||||
|
<main class="content">
|
||||||
|
<h1 class="list-title">标签:{{ $tag->name }}</h1>
|
||||||
|
|
||||||
|
@forelse($posts as $post)
|
||||||
|
@include('partials.post-card', ['post' => $post])
|
||||||
|
@empty
|
||||||
|
<div class="empty">该标签暂无文章</div>
|
||||||
|
@endforelse
|
||||||
|
|
||||||
|
@include('partials.pagination', ['paginator' => $posts])
|
||||||
|
</main>
|
||||||
|
@include('partials.sidebar')
|
||||||
|
</div>
|
||||||
|
@include('partials.footer')
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
@php $pageTitle = '标签 - '.$siteName; @endphp
|
||||||
|
@include('partials.head')
|
||||||
|
<body>
|
||||||
|
@include('partials.header')
|
||||||
|
<div class="container main-layout">
|
||||||
|
<main class="content">
|
||||||
|
<h1 class="list-title">全部标签</h1>
|
||||||
|
<div class="tag-cloud big">
|
||||||
|
@forelse($tags as $tag)
|
||||||
|
<a href="{{ route('tags.show', $tag->slug ?: $tag->name) }}" class="tag-link" style="font-size: {{ max(0.9, min(1.8, 0.9 + $tag->posts_count * 0.1)) }}em">
|
||||||
|
{{ $tag->name }} ({{ $tag->posts_count }})
|
||||||
|
</a>
|
||||||
|
@empty
|
||||||
|
<div class="empty">暂无标签</div>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
@include('partials.sidebar')
|
||||||
|
</div>
|
||||||
|
@include('partials.footer')
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+99
-2
@@ -1,7 +1,104 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Blog\Controllers\ArchiveController;
|
||||||
|
use App\Blog\Controllers\AuthController;
|
||||||
|
use App\Blog\Controllers\CategoryController;
|
||||||
|
use App\Blog\Controllers\CommentController;
|
||||||
|
use App\Blog\Controllers\FeedController;
|
||||||
|
use App\Blog\Controllers\HomeController;
|
||||||
|
use App\Blog\Controllers\LegacyController;
|
||||||
|
use App\Blog\Controllers\LinkController;
|
||||||
|
use App\Blog\Controllers\PostController;
|
||||||
|
use App\Blog\Controllers\SearchController;
|
||||||
|
use App\Blog\Controllers\TagController;
|
||||||
|
use App\Blog\Controllers\ThemeAssetController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| 前台规范路由
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 首页:兼容老式 /?action=xxx 查询串(301)
|
||||||
Route::get('/', function () {
|
Route::get('/', function () {
|
||||||
return view('welcome');
|
if (request()->filled('action')) {
|
||||||
});
|
return app(LegacyController::class)->resolveAction(request());
|
||||||
|
}
|
||||||
|
|
||||||
|
return app(HomeController::class)->index();
|
||||||
|
})->name('home');
|
||||||
|
|
||||||
|
Route::get('/posts/{slug}', [PostController::class, 'show'])->name('posts.show');
|
||||||
|
Route::post('/posts/{post}/comments', [CommentController::class, 'store'])->name('comments.store');
|
||||||
|
Route::get('/category/{slug}', [CategoryController::class, 'show'])->name('category.show');
|
||||||
|
Route::get('/archives', [ArchiveController::class, 'index'])->name('archives');
|
||||||
|
Route::get('/archives/{year}/{month}', [ArchiveController::class, 'month'])->name('archives.month');
|
||||||
|
Route::get('/tags', [TagController::class, 'index'])->name('tags');
|
||||||
|
Route::get('/tags/{slug}', [TagController::class, 'show'])->name('tags.show');
|
||||||
|
Route::get('/search', [SearchController::class, 'index'])->name('search');
|
||||||
|
Route::get('/links', [LinkController::class, 'index'])->name('links');
|
||||||
|
Route::get('/comments', [CommentController::class, 'index'])->name('comments');
|
||||||
|
|
||||||
|
Route::get('/login', [AuthController::class, 'showLogin'])->name('login');
|
||||||
|
Route::post('/login', [AuthController::class, 'login']);
|
||||||
|
Route::post('/logout', [AuthController::class, 'logout'])->name('logout');
|
||||||
|
Route::get('/register', [AuthController::class, 'showRegister'])->name('register');
|
||||||
|
Route::post('/register', [AuthController::class, 'register']);
|
||||||
|
Route::get('/profile', [AuthController::class, 'profile'])->middleware('auth')->name('profile');
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| 稳定路径:RSS / Sitemap / Robots
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
Route::get('/rss.xml', [FeedController::class, 'rss'])->name('feed.rss');
|
||||||
|
Route::get('/rss.php', [FeedController::class, 'rss']);
|
||||||
|
Route::get('/sitemap.xml', [FeedController::class, 'sitemap'])->name('feed.sitemap');
|
||||||
|
Route::get('/robots.txt', [FeedController::class, 'robots'])->name('robots');
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| 主题资产(开发模式直接流式返回;生产可用 theme:publish 后由 Web 服务器托管)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
Route::get('/themes/{theme}/assets/{path}', [ThemeAssetController::class, 'show'])
|
||||||
|
->where('path', '.*')
|
||||||
|
->name('theme.assets');
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| sablog 老 URL 兼容(301)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
Route::get('/show-{id}-{page}.html', [LegacyController::class, 'showRewritten'])
|
||||||
|
->where(['id' => '\d+', 'page' => '\d+']);
|
||||||
|
Route::get('/show-{id}.html', [LegacyController::class, 'showRewritten'])->where('id', '\d+');
|
||||||
|
Route::get('/category-{cid}-{page}.html', [LegacyController::class, 'categoryRewritten'])
|
||||||
|
->where(['cid' => '\d+', 'page' => '\d+']);
|
||||||
|
Route::get('/category-{cid}.html', [LegacyController::class, 'categoryRewritten'])->where('cid', '\d+');
|
||||||
|
Route::get('/archives-{date}-{page}.html', [LegacyController::class, 'archivesRewritten'])
|
||||||
|
->where(['date' => '\d+', 'page' => '\d+']);
|
||||||
|
Route::get('/archives-{date}.html', [LegacyController::class, 'archivesRewritten'])->where('date', '\d+');
|
||||||
|
Route::get('/tagslist-{page}.html', fn () => redirect()->route('tags', [], 301))->where('page', '\d+');
|
||||||
|
Route::get('/tagslist.html', fn () => redirect()->route('tags', [], 301));
|
||||||
|
Route::get('/comments-{page}.html', fn () => redirect()->route('comments', [], 301))->where('page', '\d+');
|
||||||
|
Route::get('/comments.shtml', fn () => redirect()->route('comments', [], 301));
|
||||||
|
Route::get('/search-{page}.html', fn () => redirect()->route('search', [], 301))->where('page', '\d+');
|
||||||
|
Route::get('/search.shtml', fn () => redirect()->route('search', [], 301));
|
||||||
|
Route::get('/links.shtml', fn () => redirect()->route('links', [], 301));
|
||||||
|
Route::get('/reg.shtml', fn () => redirect()->route('register', [], 301));
|
||||||
|
Route::get('/login.shtml', fn () => redirect()->route('login', [], 301));
|
||||||
|
Route::get('/tag/{name}', [LegacyController::class, 'tagByName']);
|
||||||
|
|
||||||
|
Route::get('/index.php', fn () => app(LegacyController::class)->resolveAction(request()));
|
||||||
|
|
||||||
|
// 旧 PHP 入口
|
||||||
|
Route::get('/sitemap.php', fn () => redirect()->route('feed.sitemap', [], 301));
|
||||||
|
Route::get('/attachment.php', [LegacyController::class, 'attachment']);
|
||||||
|
Route::post('/post.php', [LegacyController::class, 'postAction']);
|
||||||
|
Route::get('/tburl.php', fn () => abort(410, 'trackback 已废弃'));
|
||||||
|
Route::get('/trackback.php', fn () => abort(410, 'trackback 已废弃'));
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/* ============ Modern 简约主题 ============ */
|
||||||
|
:root {
|
||||||
|
--bg: #fafafa;
|
||||||
|
--card: #fff;
|
||||||
|
--text: #1a1a1a;
|
||||||
|
--muted: #767676;
|
||||||
|
--accent: #e74c3c;
|
||||||
|
--border: #eaeaea;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #151515;
|
||||||
|
--card: #1e1e1e;
|
||||||
|
--text: #e8e8e8;
|
||||||
|
--muted: #9a9a9a;
|
||||||
|
--accent: #ff6b5e;
|
||||||
|
--border: #2c2c2c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; font-size: 16px; line-height: 1.8; }
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { opacity: 0.8; }
|
||||||
|
.m-container { max-width: 720px; margin: 0 auto; padding: 0 20px; }
|
||||||
|
|
||||||
|
.m-header { border-bottom: 1px solid var(--border); background: var(--card); }
|
||||||
|
.m-header .m-container { display: flex; justify-content: space-between; align-items: center; padding: 18px 20px; }
|
||||||
|
.m-logo { font-size: 22px; font-weight: 800; letter-spacing: 1px; color: var(--text); }
|
||||||
|
.m-nav { display: flex; gap: 18px; font-size: 14px; align-items: center; }
|
||||||
|
.m-nav a.active { font-weight: 700; }
|
||||||
|
.m-link-btn { background: none; border: none; color: var(--accent); font-size: 14px; cursor: pointer; padding: 0; }
|
||||||
|
.inline-form { display: inline; }
|
||||||
|
|
||||||
|
.main-layout { padding: 36px 20px; }
|
||||||
|
.main-layout .content { max-width: 720px; margin: 0 auto; }
|
||||||
|
|
||||||
|
.post-card, .post-full, .comments-section { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 26px 30px; margin-bottom: 22px; }
|
||||||
|
.post-card.sticky { box-shadow: inset 3px 0 0 var(--accent); }
|
||||||
|
.post-title { font-size: 20px; margin-bottom: 8px; line-height: 1.45; }
|
||||||
|
.post-card .post-title a { color: var(--text); }
|
||||||
|
.badge { background: var(--accent); color: #fff; font-size: 11px; padding: 2px 7px; border-radius: 3px; margin-right: 6px; vertical-align: 2px; }
|
||||||
|
.post-meta { color: var(--muted); font-size: 13px; margin-bottom: 12px; }
|
||||||
|
.post-excerpt { color: var(--muted); }
|
||||||
|
.post-footer { margin-top: 12px; display: flex; gap: 12px; flex-wrap: wrap; font-size: 14px; }
|
||||||
|
.read-more { font-weight: 600; }
|
||||||
|
|
||||||
|
.post-full .post-title { font-size: 28px; }
|
||||||
|
.post-content { margin-top: 14px; }
|
||||||
|
.post-content img { max-width: 100%; border-radius: 8px; }
|
||||||
|
.post-content pre { background: #0d1117; color: #c9d1d9; padding: 16px; border-radius: 8px; overflow-x: auto; margin: 14px 0; }
|
||||||
|
.post-content code { background: rgba(128,128,128,.15); padding: 2px 6px; border-radius: 4px; font-size: 14px; }
|
||||||
|
.post-content pre code { background: none; padding: 0; }
|
||||||
|
.post-content blockquote { border-left: 3px solid var(--accent); padding-left: 16px; color: var(--muted); margin: 14px 0; }
|
||||||
|
.post-content h1, .post-content h2, .post-content h3 { margin: 20px 0 8px; }
|
||||||
|
|
||||||
|
.post-cover { margin: -26px -30px 18px; border-radius: 12px 12px 0 0; overflow: hidden; }
|
||||||
|
.post-cover img { width: 100%; display: block; max-height: 380px; object-fit: cover; }
|
||||||
|
|
||||||
|
.tag-link { color: var(--accent); font-size: 14px; margin-right: 8px; }
|
||||||
|
|
||||||
|
.comments-section { margin-top: 8px; }
|
||||||
|
.section-title { font-size: 17px; margin-bottom: 14px; }
|
||||||
|
.comment { padding: 12px 0; border-bottom: 1px solid var(--border); }
|
||||||
|
.comment:last-of-type { border-bottom: none; }
|
||||||
|
.comment-head { font-size: 14px; margin-bottom: 4px; }
|
||||||
|
.comment-time { color: var(--muted); font-size: 13px; }
|
||||||
|
.comment-body { color: var(--text); font-size: 15px; }
|
||||||
|
.comment-form { margin-top: 14px; }
|
||||||
|
.form-row { margin-bottom: 12px; }
|
||||||
|
.form-row label { display: block; font-size: 14px; margin-bottom: 4px; color: var(--muted); }
|
||||||
|
.form-row input, .form-row textarea { width: 100%; padding: 10px 14px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg); color: var(--text); font-size: 15px; font-family: inherit; }
|
||||||
|
.honeypot { position: absolute; left: -9999px; opacity: 0; }
|
||||||
|
.error { color: var(--accent); font-size: 13px; }
|
||||||
|
.btn { background: var(--accent); color: #fff; border: none; padding: 10px 24px; border-radius: 8px; font-size: 15px; cursor: pointer; }
|
||||||
|
.alert { padding: 12px 18px; border-radius: 8px; margin-bottom: 16px; font-size: 14px; }
|
||||||
|
.alert-success { background: rgba(46,160,67,.12); color: #2ea043; }
|
||||||
|
.alert-error { background: rgba(248,81,73,.12); color: #f85149; }
|
||||||
|
.empty { color: var(--muted); text-align: center; padding: 40px 0; }
|
||||||
|
|
||||||
|
.list-title { font-size: 24px; margin-bottom: 20px; }
|
||||||
|
|
||||||
|
.archive-year { font-size: 16px; margin: 16px 0 6px; }
|
||||||
|
.archive-list { list-style: none; }
|
||||||
|
.archive-posts { margin: 4px 0 12px 22px; }
|
||||||
|
.archive-posts time { color: var(--muted); font-size: 13px; }
|
||||||
|
|
||||||
|
.pagination { margin: 16px 0; }
|
||||||
|
.pagination-links { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.pagination-links a, .pagination-links .current, .pagination-links .disabled { padding: 6px 14px; border: 1px solid var(--border); border-radius: 6px; font-size: 14px; background: var(--card); color: var(--text); }
|
||||||
|
.pagination-links .current { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||||
|
.pagination-links .disabled { color: var(--muted); }
|
||||||
|
|
||||||
|
.m-footer { border-top: 1px solid var(--border); padding: 26px 0 40px; margin-top: 30px; text-align: center; color: var(--muted); font-size: 13px; }
|
||||||
|
|
||||||
|
.tag-cloud.big { line-height: 2.4; }
|
||||||
|
.links-list { list-style: none; }
|
||||||
|
.links-list li { padding: 6px 0; }
|
||||||
|
.link-note { color: var(--muted); font-size: 14px; }
|
||||||
|
|
||||||
|
.auth-layout { display: flex; justify-content: center; padding: 50px 20px; }
|
||||||
|
.auth-box { width: 100%; max-width: 420px; background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 30px; }
|
||||||
|
.auth-alt { margin-top: 14px; font-size: 14px; color: var(--muted); text-align: center; }
|
||||||
|
.search-form { margin-bottom: 16px; display: flex; gap: 8px; }
|
||||||
|
.search-form input { flex: 1; padding: 10px 14px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg); color: var(--text); font-size: 15px; }
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"title": "Modern 简约",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "极简现代风格,支持深色模式",
|
||||||
|
"author": "LaraLog",
|
||||||
|
"screenshot": null
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<footer class="m-footer">
|
||||||
|
<div class="m-container">
|
||||||
|
<p>© {{ date('Y') }} {{ $siteName }}
|
||||||
|
@if($siteIcp)<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">{{ $siteIcp }}</a>@endif
|
||||||
|
</p>
|
||||||
|
<p>LaraLog · 用 Markdown 书写,用 AI 审核</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<title>{{ $pageTitle ?? $siteName }}</title>
|
||||||
|
<meta name="description" content="{{ $pageDescription ?? $siteDescription }}">
|
||||||
|
@if(($pageKeywords ?? '') !== '')
|
||||||
|
<meta name="keywords" content="{{ $pageKeywords }}">
|
||||||
|
@endif
|
||||||
|
<meta name="generator" content="LaraLog">
|
||||||
|
<link rel="canonical" href="{{ url()->current() }}">
|
||||||
|
<link rel="alternate" type="application/rss+xml" title="{{ $siteName }}" href="{{ route('feed.rss') }}">
|
||||||
|
<link rel="stylesheet" href="{{ theme('asset', 'style.css') }}">
|
||||||
|
@stack('head')
|
||||||
|
</head>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<header class="m-header">
|
||||||
|
<div class="m-container">
|
||||||
|
<a href="{{ route('home') }}" class="m-logo">{{ $siteName }}</a>
|
||||||
|
<nav class="m-nav">
|
||||||
|
<a href="{{ route('home') }}" class="{{ request()->routeIs('home') ? 'active' : '' }}">首页</a>
|
||||||
|
<a href="{{ route('archives') }}" class="{{ request()->routeIs('archives*') ? 'active' : '' }}">归档</a>
|
||||||
|
<a href="{{ route('tags') }}" class="{{ request()->routeIs('tags*') ? 'active' : '' }}">标签</a>
|
||||||
|
<a href="{{ route('links') }}" class="{{ request()->routeIs('links') ? 'active' : '' }}">友链</a>
|
||||||
|
@auth
|
||||||
|
<a href="{{ route('profile') }}">{{ auth()->user()->name }}</a>
|
||||||
|
<form action="{{ route('logout') }}" method="post" class="inline-form">
|
||||||
|
@csrf
|
||||||
|
<button type="submit" class="m-link-btn">退出</button>
|
||||||
|
</form>
|
||||||
|
@else
|
||||||
|
<a href="{{ route('login') }}">登录</a>
|
||||||
|
@endauth
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/* ============ Sablog 经典主题 ============ */
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: #eef2f5 url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==) repeat-x top;
|
||||||
|
font-family: Verdana, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: #1c62b0; text-decoration: none; }
|
||||||
|
a:hover { color: #f60; text-decoration: underline; }
|
||||||
|
|
||||||
|
#outmain { width: 960px; margin: 0 auto; }
|
||||||
|
|
||||||
|
#header { padding: 22px 10px 8px; }
|
||||||
|
.logo a { font-size: 30px; font-weight: bold; color: #1c62b0; font-family: Georgia, serif; }
|
||||||
|
.logo .description { color: #888; font-size: 12px; }
|
||||||
|
|
||||||
|
#nav { margin-top: 14px; border-top: 1px solid #d8dfe6; border-bottom: 1px solid #d8dfe6; background: #f7f9fb; }
|
||||||
|
#nav ul { list-style: none; display: flex; flex-wrap: wrap; }
|
||||||
|
#nav li { border-right: 1px solid #d8dfe6; }
|
||||||
|
#nav a { display: block; padding: 7px 18px; color: #333; font-weight: bold; font-size: 13px; }
|
||||||
|
#nav a:hover { background: #1c62b0; color: #fff; text-decoration: none; }
|
||||||
|
#nav .current_page_item a { background: #1c62b0; color: #fff; }
|
||||||
|
|
||||||
|
#page { padding: 14px 10px; display: flex; gap: 18px; align-items: flex-start; }
|
||||||
|
|
||||||
|
#content { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
.post-card, .post-full, .comments-section, .auth-box, .profile-box, .links-list {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #d8dfe6;
|
||||||
|
padding: 18px 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.post-card.sticky { border-left: 4px solid #f60; }
|
||||||
|
.post-title { font-size: 17px; margin-bottom: 6px; line-height: 1.4; }
|
||||||
|
.post-card .post-title a { color: #1c62b0; }
|
||||||
|
.badge { background: #f60; color: #fff; font-size: 11px; padding: 1px 5px; margin-right: 5px; }
|
||||||
|
.post-meta { color: #999; font-size: 12px; margin-bottom: 8px; }
|
||||||
|
.post-excerpt { color: #555; margin-bottom: 8px; }
|
||||||
|
.post-footer { font-size: 12px; }
|
||||||
|
.tag-link { color: #1c62b0; margin-right: 8px; }
|
||||||
|
|
||||||
|
.post-full .post-title { font-size: 22px; color: #1c62b0; }
|
||||||
|
.post-content { margin-top: 10px; font-size: 14px; }
|
||||||
|
.post-content img { max-width: 100%; }
|
||||||
|
.post-content pre { background: #f4f4f4; border: 1px solid #ddd; padding: 10px; overflow: auto; margin: 10px 0; }
|
||||||
|
.post-content blockquote { border-left: 4px solid #1c62b0; padding-left: 12px; color: #666; margin: 10px 0; }
|
||||||
|
.post-content h1, .post-content h2, .post-content h3 { margin: 14px 0 6px; }
|
||||||
|
|
||||||
|
.post-cover { margin: -18px -20px 12px; }
|
||||||
|
.post-cover img { width: 100%; display: block; max-height: 320px; object-fit: cover; }
|
||||||
|
|
||||||
|
.comment { padding: 10px 0; border-bottom: 1px dashed #ddd; }
|
||||||
|
.comment-head { font-size: 12px; margin-bottom: 3px; }
|
||||||
|
.comment-time { color: #999; }
|
||||||
|
.comment-body { font-size: 13px; }
|
||||||
|
.comment-form { margin-top: 12px; }
|
||||||
|
.form-row { margin-bottom: 8px; }
|
||||||
|
.form-row label { display: block; font-size: 12px; margin-bottom: 3px; }
|
||||||
|
.form-row input, .form-row textarea { width: 100%; padding: 6px 8px; border: 1px solid #ccc; font-size: 13px; font-family: inherit; }
|
||||||
|
.honeypot { position: absolute; left: -9999px; opacity: 0; }
|
||||||
|
.btn { background: #1c62b0; color: #fff; border: none; padding: 7px 18px; font-size: 13px; cursor: pointer; }
|
||||||
|
.btn:hover { background: #f60; }
|
||||||
|
.error { color: #c00; font-size: 12px; }
|
||||||
|
.alert { padding: 8px 12px; margin-bottom: 12px; border: 1px solid; }
|
||||||
|
.alert-success { background: #e8f5e9; border-color: #a5d6a7; color: #2e7d32; }
|
||||||
|
.alert-error { background: #fdecea; border-color: #f5c6cb; color: #c62828; }
|
||||||
|
.empty { color: #999; text-align: center; padding: 20px 0; }
|
||||||
|
.list-title { font-size: 18px; margin-bottom: 12px; color: #1c62b0; }
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
.sidebar { width: 280px; flex-shrink: 0; }
|
||||||
|
.sidebar .searchbox { background: #fff; border: 1px solid #d8dfe6; padding: 10px; margin-bottom: 14px; }
|
||||||
|
.sidebar .searchbox input[type=search] { width: 180px; padding: 4px; border: 1px solid #ccc; }
|
||||||
|
.sidebar .widget { background: #fff; border: 1px solid #d8dfe6; margin-bottom: 14px; }
|
||||||
|
.sidebar .widget h3 { background: #f7f9fb; border-bottom: 1px solid #d8dfe6; font-size: 13px; padding: 7px 12px; color: #1c62b0; }
|
||||||
|
.sidebar .widget ul { list-style: none; padding: 6px 12px; }
|
||||||
|
.sidebar .widget li { padding: 3px 0; font-size: 12px; border-bottom: 1px dotted #eee; }
|
||||||
|
.sidebar .tagcloud { padding: 8px 12px; }
|
||||||
|
.sidebar .tagcloud a { color: #1c62b0; margin-right: 6px; }
|
||||||
|
|
||||||
|
/* 兜底页面(归档/标签云/友链/评论/登录等使用默认布局) */
|
||||||
|
.main-layout { display: flex; gap: 18px; align-items: flex-start; padding: 14px 10px; }
|
||||||
|
.main-layout .content { flex: 1; min-width: 0; }
|
||||||
|
.main-layout .sidebar { width: 280px; }
|
||||||
|
|
||||||
|
.pagination-links { display: flex; gap: 6px; flex-wrap: wrap; margin: 12px 0; }
|
||||||
|
.pagination-links a, .pagination-links .current, .pagination-links .disabled { padding: 4px 10px; border: 1px solid #d8dfe6; background: #fff; font-size: 12px; }
|
||||||
|
.pagination-links .current { background: #1c62b0; color: #fff; }
|
||||||
|
|
||||||
|
#footer { clear: both; text-align: center; padding: 14px 0 24px; color: #888; font-size: 12px; }
|
||||||
|
#footer a { color: #1c62b0; }
|
||||||
|
|
||||||
|
.archive-year { font-size: 15px; margin: 12px 0 4px; color: #1c62b0; }
|
||||||
|
.archive-list { list-style: none; }
|
||||||
|
.archive-posts { margin: 4px 0 10px 20px; }
|
||||||
|
|
||||||
|
.links-list { list-style: none; }
|
||||||
|
.links-list li { padding: 5px 0; }
|
||||||
|
.link-note { color: #999; }
|
||||||
|
|
||||||
|
.tag-cloud.big { line-height: 2.4; }
|
||||||
|
|
||||||
|
.auth-layout { display: flex; justify-content: center; padding: 30px 10px; }
|
||||||
|
.auth-box { width: 100%; max-width: 420px; }
|
||||||
|
.auth-alt { margin-top: 12px; font-size: 12px; }
|
||||||
|
.search-form { margin-bottom: 12px; }
|
||||||
|
.search-form input { padding: 5px; border: 1px solid #ccc; width: 220px; }
|
||||||
|
|
||||||
|
.inline-form { display: inline; }
|
||||||
|
.link-btn { background: none; border: none; color: #1c62b0; cursor: pointer; font-size: 13px; padding: 0; font-weight: bold; }
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"title": "Sablog 经典",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "移植自 SaBlog-X 经典两栏风格",
|
||||||
|
"author": "LaraLog",
|
||||||
|
"screenshot": null
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
@include('partials.head')
|
||||||
|
<body>
|
||||||
|
@include('partials.header')
|
||||||
|
<div id="content">
|
||||||
|
@if(session('success'))
|
||||||
|
<div class="alert alert-success">{{ session('success') }}</div>
|
||||||
|
@endif
|
||||||
|
@if(session('error'))
|
||||||
|
<div class="alert alert-error">{{ session('error') }}</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@forelse($posts as $post)
|
||||||
|
@include('partials.post-card', ['post' => $post])
|
||||||
|
@empty
|
||||||
|
<div class="empty">暂无文章</div>
|
||||||
|
@endforelse
|
||||||
|
|
||||||
|
@include('partials.pagination', ['paginator' => $posts])
|
||||||
|
</div>
|
||||||
|
@include('partials.sidebar')
|
||||||
|
@include('partials.footer')
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
@php
|
||||||
|
$pageTitle = ($archiveTitle ?? $category->name ?? '文章列表').' - '.$siteName;
|
||||||
|
@endphp
|
||||||
|
@include('partials.head')
|
||||||
|
<body>
|
||||||
|
@include('partials.header')
|
||||||
|
<div id="content">
|
||||||
|
<h1 class="list-title">{{ $archiveTitle ?? ($category->name ?? '文章列表') }}</h1>
|
||||||
|
|
||||||
|
@forelse($posts as $post)
|
||||||
|
@include('partials.post-card', ['post' => $post])
|
||||||
|
@empty
|
||||||
|
<div class="empty">该分类暂无文章</div>
|
||||||
|
@endforelse
|
||||||
|
|
||||||
|
@include('partials.pagination', ['paginator' => $posts])
|
||||||
|
</div>
|
||||||
|
@include('partials.sidebar')
|
||||||
|
@include('partials.footer')
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<div id="footer">
|
||||||
|
<p>© {{ date('Y') }} {{ $siteName }}
|
||||||
|
@if($siteIcp)<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">{{ $siteIcp }}</a>@endif
|
||||||
|
</p>
|
||||||
|
<p>Powered by LaraLog · Laravel · Filament</p>
|
||||||
|
</div>
|
||||||
|
</div>{{-- #page --}}
|
||||||
|
</div>{{-- #outmain --}}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<title>{{ $pageTitle ?? $siteName }}</title>
|
||||||
|
<meta name="description" content="{{ $pageDescription ?? $siteDescription }}">
|
||||||
|
@if(($pageKeywords ?? '') !== '')
|
||||||
|
<meta name="keywords" content="{{ $pageKeywords }}">
|
||||||
|
@endif
|
||||||
|
<meta name="generator" content="LaraLog">
|
||||||
|
<link rel="canonical" href="{{ url()->current() }}">
|
||||||
|
<link rel="alternate" type="application/rss+xml" title="{{ $siteName }}" href="{{ route('feed.rss') }}">
|
||||||
|
<link rel="stylesheet" href="{{ theme('asset', 'style.css') }}">
|
||||||
|
@stack('head')
|
||||||
|
</head>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<div id="outmain">
|
||||||
|
<div id="header">
|
||||||
|
<div class="logo">
|
||||||
|
<a href="{{ route('home') }}">{{ $siteName }}</a>
|
||||||
|
<p class="description">{{ $siteDescription }}</p>
|
||||||
|
</div>
|
||||||
|
<div id="nav">
|
||||||
|
<ul>
|
||||||
|
<li class="{{ request()->routeIs('home') ? 'current_page_item' : '' }}"><a href="{{ route('home') }}">首页</a></li>
|
||||||
|
<li class="{{ request()->routeIs('archives*') ? 'current_page_item' : '' }}"><a href="{{ route('archives') }}">归档</a></li>
|
||||||
|
<li class="{{ request()->routeIs('tags*') ? 'current_page_item' : '' }}"><a href="{{ route('tags') }}">标签</a></li>
|
||||||
|
<li class="{{ request()->routeIs('links') ? 'current_page_item' : '' }}"><a href="{{ route('links') }}">友链</a></li>
|
||||||
|
<li class="{{ request()->routeIs('comments') ? 'current_page_item' : '' }}"><a href="{{ route('comments') }}">评论</a></li>
|
||||||
|
<li><a href="{{ route('feed.rss') }}">RSS</a></li>
|
||||||
|
@auth
|
||||||
|
<li><a href="{{ route('profile') }}">{{ auth()->user()->name }}</a></li>
|
||||||
|
<li>
|
||||||
|
<form action="{{ route('logout') }}" method="post" class="inline-form">
|
||||||
|
@csrf
|
||||||
|
<button type="submit" class="link-btn">退出</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
@else
|
||||||
|
<li><a href="{{ route('login') }}">登录</a></li>
|
||||||
|
<li><a href="{{ route('register') }}">注册</a></li>
|
||||||
|
@endauth
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="page">
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<div class="sidebar">
|
||||||
|
<div class="searchbox">
|
||||||
|
<form action="{{ route('search') }}" method="get">
|
||||||
|
<input type="search" name="q" value="{{ request('q', request('keyword', '')) }}" placeholder="搜索文章">
|
||||||
|
<input type="submit" value="搜索">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="widget">
|
||||||
|
<h3>分类</h3>
|
||||||
|
<ul>
|
||||||
|
@forelse($categories as $category)
|
||||||
|
<li><a href="{{ route('category.show', $category->slug ?: $category->id) }}">{{ $category->name }}</a> ({{ $category->post_count }})</li>
|
||||||
|
@empty
|
||||||
|
<li>暂无分类</li>
|
||||||
|
@endforelse
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="widget">
|
||||||
|
<h3>最新文章</h3>
|
||||||
|
<ul>
|
||||||
|
@forelse($recentPosts as $post)
|
||||||
|
<li><a href="{{ route('posts.show', $post->slug ?: $post->id) }}">{{ $post->title }}</a></li>
|
||||||
|
@empty
|
||||||
|
<li>暂无文章</li>
|
||||||
|
@endforelse
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="widget">
|
||||||
|
<h3>最新评论</h3>
|
||||||
|
<ul>
|
||||||
|
@forelse($recentComments as $comment)
|
||||||
|
<li><a href="{{ route('posts.show', $comment->post->slug ?: $comment->post->id) }}#comment-{{ $comment->id }}">{{ $comment->author_name }}:{{ Str::limit(strip_tags($comment->content), 20) }}</a></li>
|
||||||
|
@empty
|
||||||
|
<li>暂无评论</li>
|
||||||
|
@endforelse
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="widget">
|
||||||
|
<h3>标签</h3>
|
||||||
|
<p class="tagcloud">
|
||||||
|
@forelse($hotTags as $tag)
|
||||||
|
<a href="{{ route('tags.show', $tag->slug ?: $tag->name) }}">{{ $tag->name }}</a>
|
||||||
|
@empty
|
||||||
|
暂无标签
|
||||||
|
@endforelse
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="widget">
|
||||||
|
<h3>友情链接</h3>
|
||||||
|
<ul>
|
||||||
|
@forelse($links as $link)
|
||||||
|
<li><a href="{{ $link->url }}" target="_blank" rel="noopener">{{ $link->name }}</a></li>
|
||||||
|
@empty
|
||||||
|
<li>暂无链接</li>
|
||||||
|
@endforelse
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="widget">
|
||||||
|
<h3>统计</h3>
|
||||||
|
<ul>
|
||||||
|
<li>文章 {{ $blogStats['posts'] }} 篇</li>
|
||||||
|
<li>评论 {{ $blogStats['comments'] }} 条</li>
|
||||||
|
<li>标签 {{ $blogStats['tags'] }} 个</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
@php $pageTitle = '搜索 - '.$siteName; @endphp
|
||||||
|
@include('partials.head')
|
||||||
|
<body>
|
||||||
|
@include('partials.header')
|
||||||
|
<div id="content">
|
||||||
|
<h1 class="list-title">搜索:{{ $keyword ?: '全部' }}</h1>
|
||||||
|
<form action="{{ route('search') }}" method="get" class="search-form">
|
||||||
|
<input type="search" name="q" value="{{ $keyword }}" placeholder="输入关键词搜索文章">
|
||||||
|
<button type="submit" class="btn">搜索</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
@forelse($posts as $post)
|
||||||
|
@include('partials.post-card', ['post' => $post])
|
||||||
|
@empty
|
||||||
|
<div class="empty">没有找到相关文章</div>
|
||||||
|
@endforelse
|
||||||
|
|
||||||
|
@include('partials.pagination', ['paginator' => $posts])
|
||||||
|
</div>
|
||||||
|
@include('partials.sidebar')
|
||||||
|
@include('partials.footer')
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
@php
|
||||||
|
$pageTitle = $post->title.' - '.$siteName;
|
||||||
|
$pageDescription = Str::limit(strip_tags($post->excerpt_or_fallback), 150);
|
||||||
|
$pageKeywords = $post->keywords ?: ($post->tags->pluck('name')->implode(','));
|
||||||
|
@endphp
|
||||||
|
@include('partials.head')
|
||||||
|
<body>
|
||||||
|
@include('partials.header')
|
||||||
|
<div id="content">
|
||||||
|
@if(session('success'))
|
||||||
|
<div class="alert alert-success">{{ session('success') }}</div>
|
||||||
|
@endif
|
||||||
|
@if(session('error'))
|
||||||
|
<div class="alert alert-error">{{ session('error') }}</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<article class="post-full">
|
||||||
|
@if($cover = $post->getFirstMedia('cover'))
|
||||||
|
<div class="post-cover"><img src="{{ $cover->getUrl('card') ?: $cover->getUrl() }}" alt="{{ $post->title }}"></div>
|
||||||
|
@endif
|
||||||
|
<h1 class="post-title">{{ $post->title }}</h1>
|
||||||
|
<div class="post-meta">
|
||||||
|
<time datetime="{{ $post->published_at->toIso8601String() }}">{{ $post->published_at->format('Y-m-d H:i') }}</time>
|
||||||
|
@if($post->author)<span>· {{ $post->author->name }}</span>@endif
|
||||||
|
@if($post->category)
|
||||||
|
<span>· <a href="{{ route('category.show', $post->category->slug ?: $post->category->id) }}">{{ $post->category->name }}</a></span>
|
||||||
|
@endif
|
||||||
|
<span>· 阅读 {{ $post->views }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="post-content">
|
||||||
|
{!! $contentHtml !!}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($post->tags->isNotEmpty())
|
||||||
|
<div class="post-tags">
|
||||||
|
@foreach($post->tags as $tag)
|
||||||
|
<a href="{{ route('tags.show', $tag->slug ?: $tag->name) }}" class="tag-link">#{{ $tag->name }}</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</article>
|
||||||
|
|
||||||
|
@if(! $post->close_comment)
|
||||||
|
<section id="comments" class="comments-section">
|
||||||
|
<h2 class="section-title">评论 ({{ $comments->total() }})</h2>
|
||||||
|
|
||||||
|
@forelse($comments as $comment)
|
||||||
|
<div class="comment" id="comment-{{ $comment->id }}">
|
||||||
|
<div class="comment-head">
|
||||||
|
<strong>{{ $comment->author_name }}</strong>
|
||||||
|
@if($comment->author_url)<span>· <a href="{{ $comment->author_url }}" target="_blank" rel="noopener nofollow">访问主页</a></span>@endif
|
||||||
|
<span class="comment-time">· {{ $comment->created_at->format('Y-m-d H:i') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="comment-body">{!! nl2br(e($comment->content)) !!}</div>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<p class="empty">暂无评论</p>
|
||||||
|
@endforelse
|
||||||
|
|
||||||
|
{{ $comments->links('partials.pagination-links') }}
|
||||||
|
|
||||||
|
<form method="post" action="{{ route('comments.store', $post) }}" class="comment-form" id="comment-form">
|
||||||
|
@csrf
|
||||||
|
<div class="form-row">
|
||||||
|
@auth
|
||||||
|
<input type="hidden" name="author_name" value="{{ auth()->user()->name }}">
|
||||||
|
@else
|
||||||
|
<input type="text" name="author_name" placeholder="昵称 *" required value="{{ old('author_name') }}">
|
||||||
|
<input type="email" name="author_email" placeholder="邮箱(不公开)" value="{{ old('author_email') }}">
|
||||||
|
<input type="url" name="author_url" placeholder="网站(可选)" value="{{ old('author_url') }}">
|
||||||
|
@endauth
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<textarea name="content" rows="5" placeholder="写下你的评论..." required minlength="{{ blog_setting('comment_min_len', 2) }}">{{ old('content') }}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="honeypot"><label>网站 <input type="text" name="website" tabindex="-1" autocomplete="off"></label></div>
|
||||||
|
@error('content')<p class="error">{{ $message }}</p>@enderror
|
||||||
|
<button type="submit" class="btn">发表评论</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@include('partials.sidebar')
|
||||||
|
@include('partials.footer')
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
@php $pageTitle = '标签:'.$tag->name.' - '.$siteName; @endphp
|
||||||
|
@include('partials.head')
|
||||||
|
<body>
|
||||||
|
@include('partials.header')
|
||||||
|
<div id="content">
|
||||||
|
<h1 class="list-title">标签:{{ $tag->name }}</h1>
|
||||||
|
|
||||||
|
@forelse($posts as $post)
|
||||||
|
@include('partials.post-card', ['post' => $post])
|
||||||
|
@empty
|
||||||
|
<div class="empty">该标签暂无文章</div>
|
||||||
|
@endforelse
|
||||||
|
|
||||||
|
@include('partials.pagination', ['paginator' => $posts])
|
||||||
|
</div>
|
||||||
|
@include('partials.sidebar')
|
||||||
|
@include('partials.footer')
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user