基于 Laravel 5.7 开发博客应用系列(七) —— 给博客套上 Claen Blog 主题 & 完善博客前台功能

在本节中我们将会为博客添加 Clean Blog 主题,让博客前台看上去更加大气美观。

1、使用 Clean Blog

Clean Blog 是 Start Bootstrap 提供的一个免费博客模板,本节我们将使用该模板美化博客前台页面。

使用 NPM 获取 Clean Blog

首先我们使用 NPM 下载 Clean Blog:

npm install startbootstrap-clean-blog --save-dev

使用 Laravel Mix 管理 Clean Blog

resources/sass/app.scss 中引入 Clean Blog 的 Sass 文件:

// Clean Blog
@import "~startbootstrap-clean-blog/scss/clean-blog";

然后运行 npm run dev 重新编译前端资源,新添加的 Clean Blog 的 Sass 资源文件就会通过 Laravel Mix 编译合并到 public/css/app.css 中。

上传顶部背景图片

为了显示博客页面顶部背景图片,我们需要先在后台 http://blog57.test/admin/upload 上传 Clean Blog 提供的四张顶部图片(这些图片位于 node_modules/startbootstrap-clean-blog/img 目录下),我们将这些图片上传到 uploads 目录下:

  • about-bg.jpg
  • contact-bg.jpg
  • home-bg.jpg
  • post-bg.jpg

2、创建 PostService 服务

我们最后一次接触 BlogController 还是在十分钟创建博客应用那一节,那个时候我们还没有为文章添加标签功能。

如果请求参数中指定了标签,那么我们需要根据该标签来过滤要显示的文章。要实现该功能,我们创建一个独立的服务类来聚合指定标签文章,而不是将业务逻辑一股脑写到控制器中。

首先,在 app/Services 目录下创建一个 PostService 文件,编辑其内容如下:

<?php
namespace App\Services;

use App\Models\Post;
use App\Models\Tag;
use Carbon\Carbon;

class PostService
{
    protected $tag;

    /**
     * 控制器
     *
     * @param string|null $tag
     */
    public function __construct($tag)
    {
        $this->tag = $tag;
    }

    public function lists()
    {
        if ($this->tag) {
            return $this->tagIndexData($this->tag);
        }
        return $this->normalIndexData();
    }

    /**
     * Return data for normal index page
     *
     * @return array
     */
    protected function normalIndexData()
    {
        $posts = Post::with('tags')
            ->where('published_at', '<=', Carbon::now())
            ->where('is_draft', 0)
            ->orderBy('published_at', 'desc')
            ->simplePaginate(config('blog.posts_per_page'));

        return [
            'title' => config('blog.title'),
            'subtitle' => config('blog.subtitle'),
            'posts' => $posts,
            'page_image' => config('blog.page_image'),
            'meta_description' => config('blog.description'),
            'reverse_direction' => false,
            'tag' => null,
        ];
    }

    /**
     * Return data for a tag index page
     *
     * @param string $tag
     * @return array
     */
    protected function tagIndexData($tag)
    {
        $tag = Tag::where('tag', $tag)->firstOrFail();
        $reverse_direction = (bool)$tag->reverse_direction;

        $posts = Post::where('published_at', '<=', Carbon::now())
            ->whereHas('tags', function ($q) use ($tag) {
                $q->where('tag', '=', $tag->tag);
            })
            ->where('is_draft', 0)
            ->orderBy('published_at', $reverse_direction ? 'asc' : 'desc')
            ->simplePaginate(config('blog.posts_per_page'));
        $posts->appends('tag', $tag->tag);

        $page_image = $tag->page_image ? : config('blog.page_image');

        return [
            'title' => $tag->title,
            'subtitle' => $tag->subtitle,
            'posts' => $posts,
            'page_image' => $page_image,
            'tag' => $tag,
            'reverse_direction' => $reverse_direction,
            'meta_description' => $tag->meta_description ?: config('blog.description'),
        ];
    }
}

获取文章列表调用的是 lists 方法,在该方法中,如果传入标签,那么调用 tagIndexData 方法返回根据标签进行过滤的文章列表,否则调用 normalIndexData 返回正常文章列表。

注意到我们返回的数据包含更多字段了吗?在十分钟创建博客应用中我们仅仅返回 $posts 并将其传递到视图,现在我们返回了所有信息。

3、更新控制器 BlogController

修改 BlogController.php 内容如下:

<?php

namespace App\Http\Controllers;

use App\Models\Post;
use App\Models\Tag;
use App\Services\PostService;
use Illuminate\Http\Request;

class BlogController extends Controller
{
    public function index(Request $request)
    {
        $tag = $request->get('tag');
        $postService = new PostService($tag);
        $data = $postService->lists();
        $layout = $tag ? Tag::layout($tag) : 'blog.layouts.index';
        return view($layout, $data);
    }

    public function showPost($slug, Request $request)
    {
        $post = Post::with('tags')->where('slug', $slug)->firstOrFail();
        $tag = $request->get('tag');
        if ($tag) {
            $tag = Tag::where('tag', $tag)->firstOrFail();
        }
        return view($post->layout, compact('post', 'tag'));
    }
}

我们在 index() 中先从请求中获取 $tag 值(没有的话为 null ),然后调用刚刚创建的 PostService 服务来获取文章数据。

showPost() 方法用于显示文章详情,这里我们使用了渴求式加载获取指定文章标签信息。

4、引入前端资源

还有很多事情要做,比如视图创建,但在此之前,我们先引入要用到的前端资源。

编辑 blog.js

resources/js/app.js 末尾添加如下代码:

/**
 * Blog Javascript
 * Copied from Clean Blog v1.0.0 (http://startbootstrap.com)
 */

// Navigation Scripts to Show Header on Scroll-Up
jQuery(document).ready(function ($) {
    var MQL = 1170;

    //primary navigation slide-in effect
    if ($(window).width() > MQL) {
        var headerHeight = $('.navbar-custom').height();
        $(window).on('scroll', {
                previousTop: 0
            },
            function () {
                var currentTop = $(window).scrollTop();

                //if user is scrolling up
                if (currentTop < this.previousTop) {
                    if (currentTop > 0 && $('.navbar-custom').hasClass('is-fixed')) {
                        $('.navbar-custom').addClass('is-visible');
                    } else {
                        $('.navbar-custom').removeClass('is-visible is-fixed');
                    }
                    //if scrolling down...
                } else {
                    $('.navbar-custom').removeClass('is-visible');
                    if (currentTop > headerHeight && !$('.navbar-custom').hasClass('is-fixed')) {
                        $('.navbar-custom').addClass('is-fixed');
                    }
                }
                this.previousTop = currentTop;
            });
    }

    // Initialize tooltips
    $('[data-toggle="tooltip"]').tooltip();
});

这段代码实现了 tooltips,并且在用户滚动页面时将导航条悬浮在页面顶部。这段代码拷贝自 Clean Blog 的 js/clean-blog.js 文件。

编辑 app.sass

resources/sass/app.scss 末尾添加如下代码:

.intro-header .post-heading .meta a, article a {
    text-decoration: underline;
}

h2 {
    padding-top: 22px;
}
h3 {
    padding-top: 15px;
}

h2 + p, h3 + p, h4 + p {
    margin-top: 5px;
}

// Adjust position of captions
.caption-title {
    margin-bottom: 5px;
}
.caption-title + p {
    margin-top: 0;
}

// Change the styling of dt/dd elements
dt {
    margin-bottom: 5px;
}
dd {
    margin-left: 30px;
    margin-bottom: 10px;
}

然后运行 npm run dev 重新编译前端资源,让上述修改生效。

5、创建博客视图

接下来我们来创建用于显示文章列表及详情页的视图。

首先删除十分钟创建博客应用一节中在 resources/views/blog 目录下创建的 index.blade.phppost.blade.php

创建 blog.layouts.master 视图

resources/views/blog 目录下新建 layouts 子目录, 并在该子目录下创建 master.blade.php 布局文件,编辑该文件内容如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="description" content="{{ $meta_description }}">
    <meta name="author" content="{{ config('blog.author') }}">
    <meta name="csrf-token" content="{{ csrf_token() }}">

    <title>{{ $title ?? config('blog.title') }}</title>

    {{-- Styles --}}
    <link href="{{ asset('css/app.css') }}" rel="stylesheet">
    @yield('styles')
</head>
<body>
    @include('blog.partials.page-nav')

    @yield('page-header')

    @yield('content')

    @include('blog.partials.page-footer')

    {{-- Scripts --}}
    <script src="{{ asset('js/app.js') }}"></script>
    @yield('scripts')
</body>
</html>

我们将基于该布局视图实现其它视图。

创建 blog.layouts.index 视图

layouts 目录下创建 index.blade.php 视图文件,编辑其内容如下:

@extends('blog.layouts.master')

@section('page-header')
    <header class="masthead" style="background-image: url('{{ page_image($page_image) }}')">
        <div class="overlay"></div>
        <div class="container">
            <div class="row">
                <div class="col-lg-8 col-md-10 mx-auto">
                    <div class="site-heading">
                        <h1>{{ $title }}</h1>
                        <span class="subheading">{{ $subtitle }}</span>
                    </div>
                </div>
            </div>
        </div>
    </header>
@stop

@section('content')
    <div class="container">
        <div class="row">
            <div class="col-lg-8 col-md-10 mx-auto">
                {{-- 文章列表 --}}
                @foreach ($posts as $post)
                    <div class="post-preview">
                        <a href="{{ $post->url($tag) }}">
                            <h2 class="post-title">{{ $post->title }}</h2>
                            @if ($post->subtitle)
                                <h3 class="post-subtitle">{{ $post->subtitle }}</h3>
                            @endif
                        </a>
                        <p class="post-meta">
                            Posted on {{ $post->published_at->format('Y-m-d') }}
                            @if ($post->tags->count())
                                in
                                {!! join(', ', $post->tagLinks()) !!}
                            @endif
                        </p>
                    </div>
                    <hr>
                @endforeach

                {{-- 分页 --}}
                <div class="clearfix">
                    {{-- Reverse direction --}}
                    @if ($reverse_direction)
                        @if ($posts->currentPage() > 1)
                            <a class="btn btn-primary float-left" href="{!! $posts->url($posts->currentPage() - 1) !!}">
                                ←
                                Previous {{ $tag->tag }} Posts
                            </a>
                        @endif
                        @if ($posts->hasMorePages())
                            <a class="btn btn-primary float-right" ref="{!! $posts->nextPageUrl() !!}">
                                Next {{ $tag->tag }} Posts
                                →
                            </a>
                        @endif
                    @else
                        @if ($posts->currentPage() > 1)
                            <a class="btn btn-primary float-left" href="{!! $posts->url($posts->currentPage() - 1) !!}">
                                ←
                                Newer {{ $tag ? $tag->tag : '' }} Posts
                            </a>
                        @endif
                        @if ($posts->hasMorePages())
                            <a class="btn btn-primary float-right" href="{!! $posts->nextPageUrl() !!}">
                                Older {{ $tag ? $tag->tag : '' }} Posts
                                →
                            </a>
                        @endif
                    @endif
                </div>
            </div>
        </div>
    </div>
@stop

该视图用于显示博客首页,其中定义了自己的 page-header,而 content 部分则循环显示文章列表及分页链接。

创建 blog.layouts.post 视图

接下来我们在 layouts 目录下创建用于显示文章详情的视图文件 post.blade.php,编辑其内容如下:

@extends('blog.layouts.master', [
  'title' => $post->title,
  'meta_description' => $post->meta_description ?? config('blog.description'),
])

@section('page-header')
    <header class="masthead" style="background-image: url('{{ page_image($post->page_image) }}')">
        <div class="overlay"></div>
        <div class="container">
            <div class="row">
                <div class="col-lg-8 col-md-10 mx-auto">
                    <div class="post-heading">
                        <h1>{{ $post->title }}</h1>
                        <h2 class="subheading">{{ $post->subtitle }}</h2>
                        <span class="meta">
                            Posted on {{ $post->published_at->format('Y-m-d') }}
                            @if ($post->tags->count())
                                in
                                {!! join(', ', $post->tagLinks()) !!}
                            @endif
                        </span>
                    </div>
                </div>
            </div>
        </div>
    </header>
@stop

@section('content')

    <div class="container">
        <div class="row">
            <div class="col-lg-8 col-md-10 mx-auto">
                {{-- 文章详情 --}}
                <article>
                    {!! $post->content_html !!}
                </article>

                <hr>

                {{-- 上一篇、下一篇导航 --}}
                <div class="clearfix">
                    {{-- Reverse direction --}}
                    @if ($tag && $tag->reverse_direction)
                        @if ($post->olderPost($tag))
                            <a class="btn btn-primary float-left" href="{!! $post->olderPost($tag)->url($tag) !!}">
                                ←
                                Previous {{ $tag->tag }} Post
                            </a>
                        @endif
                        @if ($post->newerPost($tag))
                            <a class="btn btn-primary float-right" ref="{!! $post->newerPost($tag)->url($tag) !!}">
                                Next {{ $tag->tag }} Post
                                →
                            </a>
                        @endif
                    @else
                        @if ($post->newerPost($tag))
                            <a class="btn btn-primary float-left" href="{!! $post->newerPost($tag)->url($tag) !!}">
                                ←
                                Newer {{ $tag ? $tag->tag : '' }} Post
                            </a>
                        @endif
                        @if ($post->olderPost($tag))
                            <a class="btn btn-primary float-right" href="{!! $post->olderPost($tag)->url($tag) !!}">
                                Older {{ $tag ? $tag->tag : '' }} Post
                                →
                            </a>
                        @endif
                    @endif
                </div>
            </div>
        </div>
    </div>
@stop

blog.layouts.index 一样,这里也定义了自己的 page-headercontent,分别用于渲染文章详情页的页头和文章详情。

创建 blog.partials.page-nav 视图

resources/views/blog 目录下新建一个 partials 目录,在该目录中,创建 page-nav.blade.php 并编辑其内容如下:

{{-- Navigation --}}
<nav class="navbar navbar-expand-lg navbar-light fixed-top" id="mainNav">
    <div class="container">
        {{-- Brand and toggle get grouped for better mobile display --}}
        <a class="navbar-brand" href="/">{{ config('blog.name') }}</a>
        <button class="navbar-toggler navbar-toggler-right" type="button" data-toggle="collapse" data-target="#navbarResponsive" aria-controls="navbarResponsive" aria-expanded="false" aria-label="Toggle navigation">
            导航菜单
            <i class="fas fa-bars"></i>
        </button>

        {{-- Collect the nav links, forms, and other content for toggling --}}
        <div class="collapse navbar-collapse" id="navbarResponsive">
            <ul class="navbar-nav ml-auto">
                <li class="nav-item">
                    <a class="nav-link" href="/">首页</a>
                </li>
            </ul>
        </div>
    </div>
</nav>

现在顶部导航条菜单只有一个 —— 「首页」。

创建 blog.partials.page-footer 视图

最后,我们在同一目录下创建 page-footer.blade.php 并编辑其内容如下:

<hr>
<footer>
    <div class="container">
        <div class="row">
            <div class="col-lg-8 col-md-10 mx-auto">
                <ul class="list-inline text-center">
                    <li class="list-inline-item">
                        <a href="#">
                  <span class="fa-stack fa-lg">
                    <i class="fas fa-circle fa-stack-2x"></i>
                    <i class="fab fa-twitter fa-stack-1x fa-inverse"></i>
                  </span>
                        </a>
                    </li>
                    <li class="list-inline-item">
                        <a href="#">
                  <span class="fa-stack fa-lg">
                    <i class="fas fa-circle fa-stack-2x"></i>
                    <i class="fab fa-facebook-f fa-stack-1x fa-inverse"></i>
                  </span>
                        </a>
                    </li>
                    <li class="list-inline-item">
                        <a href="#">
                  <span class="fa-stack fa-lg">
                    <i class="fas fa-circle fa-stack-2x"></i>
                    <i class="fab fa-github fa-stack-1x fa-inverse"></i>
                  </span>
                        </a>
                    </li>
                </ul>
                <p class="copyright text-muted">Copyright © {{ config('blog.author') }} 2018</p>
            </div>
        </div>
    </div>
</footer>

6、添加模型方法

要让视图能够正常显示,我们还需要新增一些模型方法。

更新 Tag 模型

Tag 模型类中新增一个 layout 方法:

/**
 * Return the index layout to use for a tag
 *
 * @param string $tag
 * @param string $default
 * @return string
 */
public static function layout($tag, $default = 'blog.index')
{
    $layout = static::where('tag', $tag)->get()->pluck('layout')->first();

    return $layout ?: $default;
}

layout 方法用于返回标签的布局,如果对应标签值不存在或者没有布局,返回默认值。

更新 Post 模型

Post 模型类作如下修改:

// 在Post模型类顶部其它use语句下面添加如下这行
use Carbon\Carbon;

// 接着在 Post 模型类中添加如下四个方法
/**
 * Return URL to post
 *
 * @param Tag $tag
 * @return string
 */
public function url(Tag $tag = null)
{
    $url = url('blog/' . $this->slug);
    if ($tag) {
        $url .= '?tag=' . urlencode($tag->tag);
    }

    return $url;
}

/**
 * Return array of tag links
 *
 * @param string $base
 * @return array
 */
public function tagLinks($base = '/blog?tag=%TAG%')
{
    $tags = $this->tags()->get()->pluck('tag')->all();
    $return = [];
    foreach ($tags as $tag) {
        $url = str_replace('%TAG%', urlencode($tag), $base);
        $return[] = '<a href="' . $url . '">' . e($tag) . '</a>';
    }
    return $return;
}

/**
 * Return next post after this one or null
 *
 * @param Tag $tag
 * @return Post
 */
public function newerPost(Tag $tag = null)
{
    $query =
        static::where('published_at', '>', $this->published_at)
            ->where('published_at', '<=', Carbon::now())
            ->where('is_draft', 0)
            ->orderBy('published_at', 'asc');
    if ($tag) {
        $query = $query->whereHas('tags', function ($q) use ($tag) {
            $q->where('tag', '=', $tag->tag);
        });
    }

    return $query->first();
}

/**
 * Return older post before this one or null
 *
 * @param Tag $tag
 * @return Post
 */
public function olderPost(Tag $tag = null)
{
    $query =
        static::where('published_at', '<', $this->published_at)
            ->where('is_draft', 0)
            ->orderBy('published_at', 'desc');
    if ($tag) {
        $query = $query->whereHas('tags', function ($q) use ($tag) {
            $q->where('tag', '=', $tag->tag);
        });
    }

    return $query->first();
}

我们为 Post 模型新增了四个方法。blog.index 视图会使用 url() 方法链接到指定文章详情页。tagLinks() 方法返回一个链接数组,每个链接都会指向首页并带上标签参数。newerPost() 方法返回下一篇文章链接,如果没有的话返回 nullolderPost() 方法返回前一篇文章链接,如果没有返回 null

7、更新博客设置

修改 config/blog.php 文件内容如下:

<?php
return [
    'name' => "Laravel 学院",
    'title' => "Laravel 学院",
    'subtitle' => 'http://laravelacademy.org',
    'description' => 'Laravel学院致力于提供优质Laravel中文学习资源',
    'author' => '学院君',
    'page_image' => 'home-bg.jpg',
    'posts_per_page' => 10,
    'uploads' => [
        'storage' => 'public',
        'webpath' => '/storage/uploads',
    ],
];

将相应的配置项修改成你自己的配置值,尤其是 uploads 配置。

8、更新示例数据

在十分钟创建博客应用中,我们设置了数据库填充器使用模型工厂生成随机数据。但是现在,数据库改变了。相应的,我们要修改填充器和模型工厂以便重新填充数据库的标签和其它新增字段。

更新数据库填充器

database/seeds 目录下有一个 DatabaseSeeder.php 文件,编辑其内容如下:

<?php

use Illuminate\Database\Seeder;
use Illuminate\Database\Eloquent\Model;

class DatabaseSeeder extends Seeder
{
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run()
   {
        Model::unguard();

        $this->call('TagsTableSeeder');
        $this->call('PostsTableSeeder');

        Model::reguard();
    }
}

其中,Model::unguard() 用于取消批量赋值白名单、黑名单属性校验,Model::reguard() 用于恢复校验。然后在同一目录下新建 TagsTableSeeder.php

<?php

use Illuminate\Database\Seeder;
use App\Models\Tag;

class TagsTableSeeder extends Seeder
{
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run()
    {
        Tag::truncate();

        factory(Tag::class, 5)->create();
    }
}

然后编辑 PostsTableSeeder.php 内容如下:

<?php

use Illuminate\Database\Seeder;
use App\Models\Post;
use App\Models\Tag;
use Illuminate\Support\Facades\DB;

class PostsTableSeeder extends Seeder
{
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run()
    {
        // Pull all the tag names from the file
        $tags = Tag::all()->pluck('tag')->all();

        Post::truncate();

        // Don't forget to truncate the pivot table
        DB::table('post_tag_pivot')->truncate();

        factory(Post::class, 20)->create()->each(function ($post) use ($tags) {

            // 30% of the time don't assign a tag
            if (mt_rand(1, 100) <= 30) {
                return;
            }

            shuffle($tags);
            $postTags = [$tags[0]];

            // 30% of the time we're assigning tags, assign 2
            if (mt_rand(1, 100) <= 30) {
                $postTags[] = $tags[1];
            }

            $post->syncTags($postTags);
        });
    }
}

最后一个填充器有点长,因为我们还为文章设置了随机标签。

更新模型工厂

接下来更新模型工厂,编辑 database/factories 目录下的 PostFactory.php 内容如下:

<?php

use Faker\Generator as Faker;
use App\Models\Post;

$factory->define(Post::class, function (Faker $faker) {
    $images = ['about-bg.jpg', 'contact-bg.jpg', 'home-bg.jpg', 'post-bg.jpg'];
    $title = $faker->sentence(mt_rand(3, 10));
    return [
        'title' => $title,
        'subtitle' => str_limit($faker->sentence(mt_rand(10, 20)), 252),
        'page_image' => $images[mt_rand(0, 3)],
        'content_raw' => join("\n\n", $faker->paragraphs(mt_rand(3, 6))),
        'published_at' => $faker->dateTimeBetween('-1 month', '+3 days'),
        'meta_description' => "Meta for $title",
        'is_draft' => false,
    ];
});

然后为 Tag 模型创建模型工厂:

php artisan make:factory TagFactory --model=Models/Tag

编辑新生成的 TagFactory 模型工厂文件如下:

<?php

use Faker\Generator as Faker;
use App\Models\Tag;

$factory->define(Tag::class, function (Faker $faker) {
    $images = ['about-bg.jpg', 'contact-bg.jpg', 'home-bg.jpg', 'post-bg.jpg'];
    $word = $faker->word;
    return [
        'tag' => $word,
        'title' => ucfirst($word),
        'subtitle' => $faker->sentence,
        'page_image' => $images[mt_rand(0, 3)],
        'meta_description' => "Meta for $word",
        'reverse_direction' => false,
    ];
});

填充数据库

最后填充数据库,首先执行如下命令将新增的填充器类加入自动加载文件:

composer dumpauto

然后登录到 Laradock 在项目根目录下运行填充命令将测试数据填充到数据库:

php artisan db:seed

9、访问博客首页及详情页

至此,博客前后端功能基本完成,访问 http://blog57.test,页面显示如下:


瞬间高大上了有木有?再次访问我们上一节使用 Markdown 格式编辑发布的文章,已经可以正常解析出来了:


好了,至此我们的博客应用开发基本完成,这已经具备一个常见博客的基本功能,并且还有着看上去还不错的外观。后面还有两节,我们将继续为博客应用锦上添花,实现联系我们、邮件队列、RSS订阅、站点地图、博客评论及分享等功能。

学院君 has written 1162 articles

Laravel学院院长,终身学习者

积分:146327 等级:P12 职业:手艺人 城市:杭州

4 条回复

  1. anjing anjing says:

    教程5中

    'uploads' => [
            'storage' => 'public',
            'webpath' => '/storage',
        ],

    教程7中

    ''uploads' => [
            'storage' => 'public',
            'webpath' => '/storage/uploads',
        ],'

    教程5中 /uploads 我说小程序教程中图片怎么404- -

  2. LJP88 LJP88 says:

    引入 Clean Blog重新编译前端资源发现后台样式被CleanBlog的给覆盖了,

登录后才能进行评论,立即登录?