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('%s', $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 }} 年

+ + @empty +
暂无归档
+ @endforelse +
+ @include('partials.sidebar') +
+@include('partials.footer') + + diff --git a/resources/views/comments.blade.php b/resources/views/comments.blade.php new file mode 100644 index 0000000..b4d2d0c --- /dev/null +++ b/resources/views/comments.blade.php @@ -0,0 +1,28 @@ +@php $pageTitle = '最新评论 - '.$siteName; @endphp +@include('partials.head') + +@include('partials.header') +
+
+

最新评论

+ + @forelse($comments as $comment) +
+
+ {{ $comment->author_name }} + · 评论于 {{ $comment->post->title }} + · {{ $comment->created_at->format('Y-m-d H:i') }} +
+
{!! nl2br(e($comment->content)) !!}
+
+ @empty +
暂无评论
+ @endforelse + + {{ $comments->links('partials.pagination-links') }} +
+ @include('partials.sidebar') +
+@include('partials.footer') + + diff --git a/resources/views/feed/rss.blade.php b/resources/views/feed/rss.blade.php new file mode 100644 index 0000000..f840c01 --- /dev/null +++ b/resources/views/feed/rss.blade.php @@ -0,0 +1,32 @@ +{!! $xmlDeclaration !!} + + + {{ $siteName }} + {{ url('/') }} + {{ $siteDescription }} + zh-CN + + {{ now()->toRssString() }} + @foreach($posts as $post) + + {{ $post->title }} + {{ route('posts.show', $post->slug ?: $post->id) }} + {{ route('posts.show', $post->slug ?: $post->id) }} + {{ $post->published_at->toRssString() }} + {{ Str::limit(strip_tags($post->excerpt_or_fallback), 300) }} + @if($post->category) + {{ $post->category->name }} + @endif + @foreach($post->tags as $tag) + {{ $tag->name }} + @endforeach + @if($post->author) + {{ $post->author->email }} ({{ $post->author->name }}) + @endif + render($post) !!} + ]]> + + @endforeach + + diff --git a/resources/views/feed/sitemap.blade.php b/resources/views/feed/sitemap.blade.php new file mode 100644 index 0000000..8fbfe7e --- /dev/null +++ b/resources/views/feed/sitemap.blade.php @@ -0,0 +1,26 @@ +{!! $xmlDeclaration !!} + + + {{ route('home') }} + daily + 1.0 + + + {{ route('archives') }} + weekly + 0.6 + + + {{ route('tags') }} + weekly + 0.4 + + @foreach($posts as $post) + + {{ route('posts.show', $post->slug ?: $post->id) }} + {{ $post->updated_at?->toAtomString() }} + monthly + 0.8 + + @endforeach + diff --git a/resources/views/index.blade.php b/resources/views/index.blade.php new file mode 100644 index 0000000..5262de5 --- /dev/null +++ b/resources/views/index.blade.php @@ -0,0 +1,25 @@ +@include('partials.head') + +@include('partials.header') +
+
+ @if(session('success')) +
{{ session('success') }}
+ @endif + @if(session('error')) +
{{ session('error') }}
+ @endif + + @forelse($posts as $post) + @include('partials.post-card', ['post' => $post]) + @empty +
暂无文章
+ @endforelse + + @include('partials.pagination', ['paginator' => $posts]) +
+ @include('partials.sidebar') +
+@include('partials.footer') + + diff --git a/resources/views/links.blade.php b/resources/views/links.blade.php new file mode 100644 index 0000000..97be616 --- /dev/null +++ b/resources/views/links.blade.php @@ -0,0 +1,23 @@ +@php $pageTitle = '友情链接 - '.$siteName; @endphp +@include('partials.head') + +@include('partials.header') +
+
+

友情链接

+ +
+ @include('partials.sidebar') +
+@include('partials.footer') + + diff --git a/resources/views/list.blade.php b/resources/views/list.blade.php new file mode 100644 index 0000000..33d50df --- /dev/null +++ b/resources/views/list.blade.php @@ -0,0 +1,23 @@ +@php + $pageTitle = ($archiveTitle ?? $category->name ?? '文章列表').' - '.$siteName; +@endphp +@include('partials.head') + +@include('partials.header') +
+
+

{{ $archiveTitle ?? ($category->name ?? '文章列表') }}

+ + @forelse($posts as $post) + @include('partials.post-card', ['post' => $post]) + @empty +
该分类暂无文章
+ @endforelse + + @include('partials.pagination', ['paginator' => $posts]) +
+ @include('partials.sidebar') +
+@include('partials.footer') + + diff --git a/resources/views/login.blade.php b/resources/views/login.blade.php new file mode 100644 index 0000000..b0e0605 --- /dev/null +++ b/resources/views/login.blade.php @@ -0,0 +1,29 @@ +@php $pageTitle = '登录 - '.$siteName; @endphp +@include('partials.head') + +@include('partials.header') +
+
+

登录

+
+ @csrf +
+ + +
+
+ + +
+
+ +
+ @error('email')

{{ $message }}

@enderror + +
+

还没有账号?立即注册

+
+
+@include('partials.footer') + + diff --git a/resources/views/partials/footer.blade.php b/resources/views/partials/footer.blade.php new file mode 100644 index 0000000..8c2ee05 --- /dev/null +++ b/resources/views/partials/footer.blade.php @@ -0,0 +1,11 @@ + diff --git a/resources/views/partials/head.blade.php b/resources/views/partials/head.blade.php new file mode 100644 index 0000000..449b960 --- /dev/null +++ b/resources/views/partials/head.blade.php @@ -0,0 +1,17 @@ + + + + + + + {{ $pageTitle ?? $siteName }} + + @if(($pageKeywords ?? '') !== '') + + @endif + + + + + @stack('head') + diff --git a/resources/views/partials/header.blade.php b/resources/views/partials/header.blade.php new file mode 100644 index 0000000..6ce1483 --- /dev/null +++ b/resources/views/partials/header.blade.php @@ -0,0 +1,30 @@ + diff --git a/resources/views/partials/pagination-links.blade.php b/resources/views/partials/pagination-links.blade.php new file mode 100644 index 0000000..091d255 --- /dev/null +++ b/resources/views/partials/pagination-links.blade.php @@ -0,0 +1,33 @@ +@if ($paginator->hasPages()) + +@endif diff --git a/resources/views/partials/pagination.blade.php b/resources/views/partials/pagination.blade.php new file mode 100644 index 0000000..76b8aa5 --- /dev/null +++ b/resources/views/partials/pagination.blade.php @@ -0,0 +1,5 @@ +@if ($paginator->hasPages()) + +@endif diff --git a/resources/views/partials/post-card.blade.php b/resources/views/partials/post-card.blade.php new file mode 100644 index 0000000..0e29545 --- /dev/null +++ b/resources/views/partials/post-card.blade.php @@ -0,0 +1,29 @@ +
+ @if($cover = $post->getFirstMedia('cover')) + + {{ $post->title }} + + @endif +

+ @if($post->is_sticky)置顶@endif + {{ $post->title }} +

+
+ + @if($post->category) + · + {{ $post->category->name }} + @endif + · 阅读 {{ $post->views }} + · 评论 {{ $post->comment_count }} +
+
+ {{ Str::limit(strip_tags($post->excerpt_or_fallback), 200) }} +
+
+ 阅读全文 » + @foreach($post->tags as $tag) + #{{ $tag->name }} + @endforeach +
+
diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php new file mode 100644 index 0000000..974feed --- /dev/null +++ b/resources/views/partials/sidebar.blade.php @@ -0,0 +1,73 @@ + diff --git a/resources/views/profile.blade.php b/resources/views/profile.blade.php new file mode 100644 index 0000000..794c5d0 --- /dev/null +++ b/resources/views/profile.blade.php @@ -0,0 +1,24 @@ +@php $pageTitle = '个人中心 - '.$siteName; $user = auth()->user(); @endphp +@include('partials.head') + +@include('partials.header') +
+
+

个人中心

+
+

昵称:{{ $user->name }}

+

邮箱:{{ $user->email }}

+ @if($user->url)

主页:{{ $user->url }}

@endif +

注册时间:{{ $user->created_at?->format('Y-m-d') }}

+

登录次数:{{ $user->logincount }}

+

角色:{{ $user->getRoleNames()->implode(', ') ?: '会员' }}

+

我的文章:{{ $user->posts()->count() }} 篇

+

我的评论:{{ $user->comments()->count() }} 条

+
+

« 返回首页

+
+ @include('partials.sidebar') +
+@include('partials.footer') + + diff --git a/resources/views/register.blade.php b/resources/views/register.blade.php new file mode 100644 index 0000000..796f8bd --- /dev/null +++ b/resources/views/register.blade.php @@ -0,0 +1,40 @@ +@php $pageTitle = '注册 - '.$siteName; @endphp +@include('partials.head') + +@include('partials.header') +
+
+

注册

+
+ @csrf +
+ + + @error('name')

{{ $message }}

@enderror +
+
+ + + @error('email')

{{ $message }}

@enderror +
+
+ + + @error('password')

{{ $message }}

@enderror +
+
+ + +
+
+ + +
+ +
+

已有账号?去登录

+
+
+@include('partials.footer') + + diff --git a/resources/views/search.blade.php b/resources/views/search.blade.php new file mode 100644 index 0000000..5393bb9 --- /dev/null +++ b/resources/views/search.blade.php @@ -0,0 +1,25 @@ +@php $pageTitle = '搜索 - '.$siteName; @endphp +@include('partials.head') + +@include('partials.header') +
+
+

搜索:{{ $keyword ?: '全部' }}

+
+ + +
+ + @forelse($posts as $post) + @include('partials.post-card', ['post' => $post]) + @empty +
没有找到相关文章
+ @endforelse + + @include('partials.pagination', ['paginator' => $posts]) +
+ @include('partials.sidebar') +
+@include('partials.footer') + + diff --git a/resources/views/show.blade.php b/resources/views/show.blade.php new file mode 100644 index 0000000..4ac91bc --- /dev/null +++ b/resources/views/show.blade.php @@ -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') + +@include('partials.header') +
+
+ @if(session('success')) +
{{ session('success') }}
+ @endif + @if(session('error')) +
{{ session('error') }}
+ @endif + +
+ @if($cover = $post->getFirstMedia('cover')) +
+ {{ $post->title }} +
+ @endif +

{{ $post->title }}

+ + +
+ {!! $contentHtml !!} +
+ + @if($post->tags->isNotEmpty()) + + @endif +
+ + @if(! $post->close_comment) +
+

评论 ({{ $comments->total() }})

+ + @forelse($comments as $comment) +
+
+ {{ $comment->author_name }} + @if($comment->author_url) + · 访问主页 + @endif + · {{ $comment->created_at->format('Y-m-d H:i') }} +
+
{!! nl2br(e($comment->content)) !!}
+
+ @empty +

暂无评论

+ @endforelse + + {{ $comments->links('partials.pagination-links') }} + +
+ @csrf +
+ @auth + + @else + + + + @endauth +
+
+ +
+ {{-- 蜜罐字段:人类不会填写 --}} +
+ @error('content')

{{ $message }}

@enderror + @if($errors->has('author_name'))

{{ $errors->first('author_name') }}

@endif + +
+
+ @endif +
+ @include('partials.sidebar') +
+@include('partials.footer') + + diff --git a/resources/views/tag.blade.php b/resources/views/tag.blade.php new file mode 100644 index 0000000..1b21d51 --- /dev/null +++ b/resources/views/tag.blade.php @@ -0,0 +1,21 @@ +@php $pageTitle = '标签:'.$tag->name.' - '.$siteName; @endphp +@include('partials.head') + +@include('partials.header') +
+
+

标签:{{ $tag->name }}

+ + @forelse($posts as $post) + @include('partials.post-card', ['post' => $post]) + @empty +
该标签暂无文章
+ @endforelse + + @include('partials.pagination', ['paginator' => $posts]) +
+ @include('partials.sidebar') +
+@include('partials.footer') + + diff --git a/resources/views/tags.blade.php b/resources/views/tags.blade.php new file mode 100644 index 0000000..8df5dcb --- /dev/null +++ b/resources/views/tags.blade.php @@ -0,0 +1,22 @@ +@php $pageTitle = '标签 - '.$siteName; @endphp +@include('partials.head') + +@include('partials.header') +
+
+

全部标签

+
+ @forelse($tags as $tag) + + {{ $tag->name }} ({{ $tag->posts_count }}) + + @empty +
暂无标签
+ @endforelse +
+
+ @include('partials.sidebar') +
+@include('partials.footer') + + diff --git a/routes/web.php b/routes/web.php index 86a06c5..eda6bfe 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,7 +1,104 @@ 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 已废弃')); diff --git a/themes/modern/assets/style.css b/themes/modern/assets/style.css new file mode 100644 index 0000000..9e364c7 --- /dev/null +++ b/themes/modern/assets/style.css @@ -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; } diff --git a/themes/modern/theme.json b/themes/modern/theme.json new file mode 100644 index 0000000..f4dad8e --- /dev/null +++ b/themes/modern/theme.json @@ -0,0 +1,7 @@ +{ + "title": "Modern 简约", + "version": "1.0.0", + "description": "极简现代风格,支持深色模式", + "author": "LaraLog", + "screenshot": null +} diff --git a/themes/modern/views/partials/footer.blade.php b/themes/modern/views/partials/footer.blade.php new file mode 100644 index 0000000..b050ba7 --- /dev/null +++ b/themes/modern/views/partials/footer.blade.php @@ -0,0 +1,8 @@ + diff --git a/themes/modern/views/partials/head.blade.php b/themes/modern/views/partials/head.blade.php new file mode 100644 index 0000000..6bfec72 --- /dev/null +++ b/themes/modern/views/partials/head.blade.php @@ -0,0 +1,17 @@ + + + + + + + {{ $pageTitle ?? $siteName }} + + @if(($pageKeywords ?? '') !== '') + + @endif + + + + + @stack('head') + diff --git a/themes/modern/views/partials/header.blade.php b/themes/modern/views/partials/header.blade.php new file mode 100644 index 0000000..86b9322 --- /dev/null +++ b/themes/modern/views/partials/header.blade.php @@ -0,0 +1,20 @@ + diff --git a/themes/sablog/assets/style.css b/themes/sablog/assets/style.css new file mode 100644 index 0000000..4eb8a3c --- /dev/null +++ b/themes/sablog/assets/style.css @@ -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; } diff --git a/themes/sablog/theme.json b/themes/sablog/theme.json new file mode 100644 index 0000000..be18eb5 --- /dev/null +++ b/themes/sablog/theme.json @@ -0,0 +1,7 @@ +{ + "title": "Sablog 经典", + "version": "1.0.0", + "description": "移植自 SaBlog-X 经典两栏风格", + "author": "LaraLog", + "screenshot": null +} diff --git a/themes/sablog/views/index.blade.php b/themes/sablog/views/index.blade.php new file mode 100644 index 0000000..2663390 --- /dev/null +++ b/themes/sablog/views/index.blade.php @@ -0,0 +1,23 @@ +@include('partials.head') + +@include('partials.header') +
+ @if(session('success')) +
{{ session('success') }}
+ @endif + @if(session('error')) +
{{ session('error') }}
+ @endif + + @forelse($posts as $post) + @include('partials.post-card', ['post' => $post]) + @empty +
暂无文章
+ @endforelse + + @include('partials.pagination', ['paginator' => $posts]) +
+@include('partials.sidebar') +@include('partials.footer') + + diff --git a/themes/sablog/views/list.blade.php b/themes/sablog/views/list.blade.php new file mode 100644 index 0000000..fce4aed --- /dev/null +++ b/themes/sablog/views/list.blade.php @@ -0,0 +1,21 @@ +@php + $pageTitle = ($archiveTitle ?? $category->name ?? '文章列表').' - '.$siteName; +@endphp +@include('partials.head') + +@include('partials.header') +
+

{{ $archiveTitle ?? ($category->name ?? '文章列表') }}

+ + @forelse($posts as $post) + @include('partials.post-card', ['post' => $post]) + @empty +
该分类暂无文章
+ @endforelse + + @include('partials.pagination', ['paginator' => $posts]) +
+@include('partials.sidebar') +@include('partials.footer') + + diff --git a/themes/sablog/views/partials/footer.blade.php b/themes/sablog/views/partials/footer.blade.php new file mode 100644 index 0000000..f0fb4fe --- /dev/null +++ b/themes/sablog/views/partials/footer.blade.php @@ -0,0 +1,8 @@ + +{{-- #page --}} +{{-- #outmain --}} diff --git a/themes/sablog/views/partials/head.blade.php b/themes/sablog/views/partials/head.blade.php new file mode 100644 index 0000000..6bfec72 --- /dev/null +++ b/themes/sablog/views/partials/head.blade.php @@ -0,0 +1,17 @@ + + + + + + + {{ $pageTitle ?? $siteName }} + + @if(($pageKeywords ?? '') !== '') + + @endif + + + + + @stack('head') + diff --git a/themes/sablog/views/partials/header.blade.php b/themes/sablog/views/partials/header.blade.php new file mode 100644 index 0000000..aac4de6 --- /dev/null +++ b/themes/sablog/views/partials/header.blade.php @@ -0,0 +1,30 @@ +
+ +
diff --git a/themes/sablog/views/partials/sidebar.blade.php b/themes/sablog/views/partials/sidebar.blade.php new file mode 100644 index 0000000..6920c32 --- /dev/null +++ b/themes/sablog/views/partials/sidebar.blade.php @@ -0,0 +1,72 @@ + diff --git a/themes/sablog/views/search.blade.php b/themes/sablog/views/search.blade.php new file mode 100644 index 0000000..25b9746 --- /dev/null +++ b/themes/sablog/views/search.blade.php @@ -0,0 +1,23 @@ +@php $pageTitle = '搜索 - '.$siteName; @endphp +@include('partials.head') + +@include('partials.header') +
+

搜索:{{ $keyword ?: '全部' }}

+
+ + +
+ + @forelse($posts as $post) + @include('partials.post-card', ['post' => $post]) + @empty +
没有找到相关文章
+ @endforelse + + @include('partials.pagination', ['paginator' => $posts]) +
+@include('partials.sidebar') +@include('partials.footer') + + diff --git a/themes/sablog/views/show.blade.php b/themes/sablog/views/show.blade.php new file mode 100644 index 0000000..76e2ac0 --- /dev/null +++ b/themes/sablog/views/show.blade.php @@ -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') + +@include('partials.header') +
+ @if(session('success')) +
{{ session('success') }}
+ @endif + @if(session('error')) +
{{ session('error') }}
+ @endif + +
+ @if($cover = $post->getFirstMedia('cover')) +
{{ $post->title }}
+ @endif +

{{ $post->title }}

+ + +
+ {!! $contentHtml !!} +
+ + @if($post->tags->isNotEmpty()) + + @endif +
+ + @if(! $post->close_comment) +
+

评论 ({{ $comments->total() }})

+ + @forelse($comments as $comment) +
+
+ {{ $comment->author_name }} + @if($comment->author_url)· 访问主页@endif + · {{ $comment->created_at->format('Y-m-d H:i') }} +
+
{!! nl2br(e($comment->content)) !!}
+
+ @empty +

暂无评论

+ @endforelse + + {{ $comments->links('partials.pagination-links') }} + +
+ @csrf +
+ @auth + + @else + + + + @endauth +
+
+ +
+
+ @error('content')

{{ $message }}

@enderror + +
+
+ @endif +
+@include('partials.sidebar') +@include('partials.footer') + + diff --git a/themes/sablog/views/tag.blade.php b/themes/sablog/views/tag.blade.php new file mode 100644 index 0000000..c12615a --- /dev/null +++ b/themes/sablog/views/tag.blade.php @@ -0,0 +1,19 @@ +@php $pageTitle = '标签:'.$tag->name.' - '.$siteName; @endphp +@include('partials.head') + +@include('partials.header') +
+

标签:{{ $tag->name }}

+ + @forelse($posts as $post) + @include('partials.post-card', ['post' => $post]) + @empty +
该标签暂无文章
+ @endforelse + + @include('partials.pagination', ['paginator' => $posts]) +
+@include('partials.sidebar') +@include('partials.footer') + +