diff --git a/app/Blog/Controllers/ArchiveController.php b/app/Blog/Controllers/ArchiveController.php
new file mode 100644
index 0000000..a639d29
--- /dev/null
+++ b/app/Blog/Controllers/ArchiveController.php
@@ -0,0 +1,34 @@
+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} 月");
+ }
+}
diff --git a/app/Blog/Controllers/AuthController.php b/app/Blog/Controllers/AuthController.php
new file mode 100644
index 0000000..a95e6f9
--- /dev/null
+++ b/app/Blog/Controllers/AuthController.php
@@ -0,0 +1,93 @@
+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');
+ }
+}
diff --git a/app/Blog/Controllers/CategoryController.php b/app/Blog/Controllers/CategoryController.php
new file mode 100644
index 0000000..5ed9165
--- /dev/null
+++ b/app/Blog/Controllers/CategoryController.php
@@ -0,0 +1,21 @@
+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'));
+ }
+}
diff --git a/app/Blog/Controllers/CommentController.php b/app/Blog/Controllers/CommentController.php
new file mode 100644
index 0000000..9fd165e
--- /dev/null
+++ b/app/Blog/Controllers/CommentController.php
@@ -0,0 +1,77 @@
+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');
+ }
+}
diff --git a/app/Blog/Controllers/Controller.php b/app/Blog/Controllers/Controller.php
new file mode 100644
index 0000000..0ad2c19
--- /dev/null
+++ b/app/Blog/Controllers/Controller.php
@@ -0,0 +1,7 @@
+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' => '',
+ ])->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' => '',
+ ])->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');
+ }
+}
diff --git a/app/Blog/Controllers/HomeController.php b/app/Blog/Controllers/HomeController.php
new file mode 100644
index 0000000..62fa970
--- /dev/null
+++ b/app/Blog/Controllers/HomeController.php
@@ -0,0 +1,19 @@
+published()
+ ->orderByDesc('is_sticky')
+ ->latest('published_at')
+ ->paginate((int) blog_setting('per_page', config('blog.per_page')));
+
+ return theme_view('index', compact('posts'));
+ }
+}
diff --git a/app/Blog/Controllers/LegacyController.php b/app/Blog/Controllers/LegacyController.php
new file mode 100644
index 0000000..65a169e
--- /dev/null
+++ b/app/Blog/Controllers/LegacyController.php
@@ -0,0 +1,215 @@
+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);
+ }
+}
diff --git a/app/Blog/Controllers/LinkController.php b/app/Blog/Controllers/LinkController.php
new file mode 100644
index 0000000..b69ef1c
--- /dev/null
+++ b/app/Blog/Controllers/LinkController.php
@@ -0,0 +1,16 @@
+where('visible', true)->orderBy('display_order')->orderBy('name')->get();
+
+ return theme_view('links', compact('links'));
+ }
+}
diff --git a/app/Blog/Controllers/PostController.php b/app/Blog/Controllers/PostController.php
new file mode 100644
index 0000000..333e6ca
--- /dev/null
+++ b/app/Blog/Controllers/PostController.php
@@ -0,0 +1,50 @@
+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, '该文章需要访问密码');
+ }
+ }
+}
diff --git a/app/Blog/Controllers/SearchController.php b/app/Blog/Controllers/SearchController.php
new file mode 100644
index 0000000..6ebe73e
--- /dev/null
+++ b/app/Blog/Controllers/SearchController.php
@@ -0,0 +1,21 @@
+published()
+ ->search($keyword)
+ ->orderByDesc('published_at')
+ ->paginate((int) blog_setting('per_page', config('blog.per_page')));
+
+ return theme_view('search', compact('posts', 'keyword'));
+ }
+}
diff --git a/app/Blog/Controllers/TagController.php b/app/Blog/Controllers/TagController.php
new file mode 100644
index 0000000..cd2a9e0
--- /dev/null
+++ b/app/Blog/Controllers/TagController.php
@@ -0,0 +1,42 @@
+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'));
+ }
+}
diff --git a/app/Blog/Controllers/ThemeAssetController.php b/app/Blog/Controllers/ThemeAssetController.php
new file mode 100644
index 0000000..be26ae6
--- /dev/null
+++ b/app/Blog/Controllers/ThemeAssetController.php
@@ -0,0 +1,45 @@
+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',
+ };
+ }
+}
diff --git a/app/Blog/Providers/ThemeServiceProvider.php b/app/Blog/Providers/ThemeServiceProvider.php
new file mode 100644
index 0000000..4a2d223
--- /dev/null
+++ b/app/Blog/Providers/ThemeServiceProvider.php
@@ -0,0 +1,44 @@
+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);
+ }
+}
diff --git a/app/Blog/Services/AttachmentImporter.php b/app/Blog/Services/AttachmentImporter.php
index a4522c8..aac39de 100644
--- a/app/Blog/Services/AttachmentImporter.php
+++ b/app/Blog/Services/AttachmentImporter.php
@@ -26,6 +26,7 @@ class AttachmentImporter
$properties = [
'downloads' => (int) ($row['downloads'] ?? 0),
'isimage' => (bool) ($row['isimage'] ?? false),
+ 'legacy_attachmentid' => (int) ($row['attachmentid'] ?? 0),
'legacy_filepath' => $row['filepath'] ?? null,
'legacy_thumb' => $row['thumb_filepath'] ?? null,
'legacy_articleid' => (int) ($row['articleid'] ?? 0),
diff --git a/app/Blog/Services/PostContentRenderer.php b/app/Blog/Services/PostContentRenderer.php
new file mode 100644
index 0000000..ce9ba8d
--- /dev/null
+++ b/app/Blog/Services/PostContentRenderer.php
@@ -0,0 +1,101 @@
+ '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('
', $media->getUrl(), e($media->name))
+ : sprintf('%s', $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);
+ }
+}
diff --git a/app/Blog/Support/ThemeManager.php b/app/Blog/Support/ThemeManager.php
new file mode 100644
index 0000000..82550de
--- /dev/null
+++ b/app/Blog/Support/ThemeManager.php
@@ -0,0 +1,112 @@
+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;
+ }
+}
diff --git a/app/Blog/View/Composers/SidebarComposer.php b/app/Blog/View/Composers/SidebarComposer.php
new file mode 100644
index 0000000..ab18cb1
--- /dev/null
+++ b/app/Blog/View/Composers/SidebarComposer.php
@@ -0,0 +1,66 @@
+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')));
+ }
+}
diff --git a/app/Console/Commands/ConvertContent.php b/app/Console/Commands/ConvertContent.php
new file mode 100644
index 0000000..5f885ed
--- /dev/null
+++ b/app/Console/Commands/ConvertContent.php
@@ -0,0 +1,72 @@
+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;
+ }
+}
diff --git a/app/Console/Commands/SablogImport.php b/app/Console/Commands/SablogImport.php
index 74d2711..de837f9 100644
--- a/app/Console/Commands/SablogImport.php
+++ b/app/Console/Commands/SablogImport.php
@@ -26,6 +26,7 @@ class SablogImport extends Command
{--prefix=sablog_ : 老表前缀}
{--attachments-dir= : 老 attachments 目录(用于同步文件,可选)}
{--sync-attachments : 同步附件文件到新存储}
+ {--convert-markdown : 导入后把 HTML 内容转为 Markdown([attach=xx] 转成 {{attach:xx}} 令牌,渲染时解析)}
{--fresh : 清空目标表后重新导入}';
protected $description = '从 SaBlog-X 老库脚本迁移数据到 laralog';
@@ -63,6 +64,10 @@ class SablogImport extends Command
$this->importLinks();
$this->importAttachments();
+ if ($this->option('convert-markdown')) {
+ $this->convertToMarkdown();
+ }
+
$this->syncCounters();
$this->newLine();
@@ -157,7 +162,7 @@ class SablogImport extends Command
$count = 0;
foreach ($this->rows('categories') as $row) {
$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'],
'slug' => $slug,
'display_order' => (int) $row['displayorder'],
@@ -182,7 +187,7 @@ class SablogImport extends Command
$count = 0;
foreach ($this->rows('users') as $row) {
$email = $this->legacyEmail($row['username'], $count);
- $user = User::query()->updateOrCreate(['id' => $row['userid']], [
+ DB::table('users')->updateOrInsert(['id' => (int) $row['userid']], [
'name' => $row['username'],
'email' => $email,
'password' => null,
@@ -197,8 +202,9 @@ class SablogImport extends Command
'updated_at' => now(),
]);
+ $user = User::find((int) $row['userid']);
$role = $groupMap[(int) $row['groupid']] ?? 'member';
- if (! $user->hasRole($role)) {
+ if ($user && ! $user->hasRole($role)) {
$user->assignRole($role);
}
$count++;
@@ -222,13 +228,14 @@ class SablogImport extends Command
$status = (int) $row['visible'] === 1 ? 'published' : 'draft';
$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,
'user_id' => (int) $row['uid'] ?: null,
'title' => $row['title'],
'slug' => $slug,
'excerpt' => $row['description'] ?: null,
'content' => $row['content'],
+ 'content_format' => 'html',
'keywords' => $row['keywords'] ?: null,
'status' => $status,
'is_sticky' => (bool) $row['stick'],
@@ -248,6 +255,7 @@ class SablogImport extends Command
private function importTags(): void
{
+ $legacyMap = [];
$count = 0;
foreach ($this->rows('tags') as $row) {
$tagName = trim($row['tag']);
@@ -262,6 +270,8 @@ class SablogImport extends Command
$tag->save();
}
+ $legacyMap[(int) $row['tagid']] = $tag->id;
+
foreach (explode(',', (string) $row['aids']) as $aid) {
$post = Post::find((int) trim($aid));
if ($post && ! $post->tags()->where('tags.id', $tag->id)->exists()) {
@@ -271,6 +281,10 @@ class SablogImport extends Command
$count++;
}
+ if ($legacyMap) {
+ Setting::set('legacy_tags', $legacyMap);
+ }
+
$this->report['tags'] = $count;
}
@@ -278,7 +292,7 @@ class SablogImport extends Command
{
$count = 0;
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'],
'author_name' => $row['author'] ?: '匿名',
'author_url' => $row['url'] ?: null,
@@ -297,7 +311,7 @@ class SablogImport extends Command
{
$count = 0;
foreach ($this->rows('links') as $row) {
- Link::query()->updateOrCreate(['id' => $row['linkid']], [
+ DB::table('links')->updateOrInsert(['id' => (int) $row['linkid']], [
'name' => $row['name'],
'url' => $row['url'],
'note' => $row['note'] ?: null,
@@ -312,6 +326,27 @@ class SablogImport extends Command
$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
{
if (! $this->db->query('SHOW TABLES LIKE "'.$this->prefix.'attachments"')->fetchColumn()) {
diff --git a/app/Models/Comment.php b/app/Models/Comment.php
index 502b14d..0c93d85 100644
--- a/app/Models/Comment.php
+++ b/app/Models/Comment.php
@@ -29,6 +29,7 @@ class Comment extends Model
protected $casts = [
'ai_review' => 'array',
+ 'created_at' => 'datetime',
];
public $timestamps = false;
diff --git a/app/Models/Post.php b/app/Models/Post.php
index c0564fa..44488a6 100644
--- a/app/Models/Post.php
+++ b/app/Models/Post.php
@@ -25,6 +25,7 @@ class Post extends Model implements HasMedia
'slug',
'excerpt',
'content',
+ 'content_format',
'keywords',
'status',
'is_sticky',
@@ -82,6 +83,11 @@ class Post extends Model implements HasMedia
public function registerMediaCollections(): void
{
$this->addMediaCollection('attachments')->useDisk(\App\Support\MediaDisk::name());
+ $this->addMediaCollection('cover')->useDisk(\App\Support\MediaDisk::name())
+ ->singleFile()
+ ->registerMediaConversions(function () {
+ $this->addMediaConversion('card')->width(1200)->height(630)->sharpen(1);
+ });
}
public function registerMediaConversions(?Media $media = null): void
diff --git a/app/Support/helpers.php b/app/Support/helpers.php
new file mode 100644
index 0000000..58ecc6d
--- /dev/null
+++ b/app/Support/helpers.php
@@ -0,0 +1,41 @@
+ $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);
+ }
+}
diff --git a/bootstrap/providers.php b/bootstrap/providers.php
index 22744d1..96b1a97 100644
--- a/bootstrap/providers.php
+++ b/bootstrap/providers.php
@@ -3,4 +3,5 @@
return [
App\Providers\AppServiceProvider::class,
App\Providers\Filament\AdminPanelProvider::class,
+ App\Blog\Providers\ThemeServiceProvider::class,
];
diff --git a/composer.json b/composer.json
index 86762cc..5cc9a76 100644
--- a/composer.json
+++ b/composer.json
@@ -10,6 +10,8 @@
"filament/filament": "^5.7",
"laravel/framework": "^12.0",
"laravel/tinker": "^2.10.1",
+ "league/commonmark": "^2.9",
+ "league/html-to-markdown": "^5.1",
"spatie/laravel-backup": "^9.0",
"spatie/laravel-medialibrary": "^11.0",
"spatie/laravel-permission": "^6.0",
@@ -33,7 +35,10 @@
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
- }
+ },
+ "files": [
+ "app/Support/helpers.php"
+ ]
},
"autoload-dev": {
"psr-4": {
diff --git a/composer.lock b/composer.lock
index 62ae8d7..8b96709 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "3a470c618c6761bd46250fdc42d222de",
+ "content-hash": "c0357e1996000075eb3879929f19e248",
"packages": [
{
"name": "anourvalar/eloquent-serialize",
@@ -2987,6 +2987,95 @@
},
"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",
"version": "1.17.0",
diff --git a/database/migrations/2026_08_11_100005_add_content_format_to_posts_table.php b/database/migrations/2026_08_11_100005_add_content_format_to_posts_table.php
new file mode 100644
index 0000000..e0252e3
--- /dev/null
+++ b/database/migrations/2026_08_11_100005_add_content_format_to_posts_table.php
@@ -0,0 +1,22 @@
+string('content_format', 10)->default('markdown')->after('content');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('posts', function (Blueprint $table) {
+ $table->dropColumn('content_format');
+ });
+ }
+};
diff --git a/public/css/blog.css b/public/css/blog.css
new file mode 100644
index 0000000..dcdfc3f
--- /dev/null
+++ b/public/css/blog.css
@@ -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; }
diff --git a/resources/views/archives.blade.php b/resources/views/archives.blade.php
new file mode 100644
index 0000000..2c6a5cb
--- /dev/null
+++ b/resources/views/archives.blade.php
@@ -0,0 +1,32 @@
+@php $pageTitle = '归档 - '.$siteName; @endphp
+@include('partials.head')
+
+@include('partials.header')
+
+
+ 文章归档
+
+ @forelse($archives as $year => $months)
+ {{ $year }} 年
+
+ @foreach($months as $month => $monthPosts)
+ -
+ {{ (int) $month }} 月
+ ({{ $monthPosts->count() }} 篇)
+
+ @foreach($monthPosts as $post)
+ - {{ $post->title }}
+ @endforeach
+
+
+ @endforeach
+
+ @empty
+ 暂无归档
+ @endforelse
+
+ @include('partials.sidebar')
+
+@include('partials.footer')
+
+