• Django个人博客系统(6-10)


    在上篇中,我们已经学会了Django的一些基本操作,本篇在其基础上进一步完善。

    6.登录注册与重置密码

    用户的登录注册是大部分网站的基本功能,而Django非常贴心地内置了用户管理模型——User,利用这个内置模型可以满足绝大多数网站的需求,但是这里由于需要用到用户头像等User中没有的字段,因此我们将用自定义的用户模型UserProfile来覆盖User
    首先新建一个userprofile的应用:

    python manage.py startapp userprofile
    

    然后在settings.py文件的INSTALLED_APPS中添加应用的名称:

    INSTALLED_APPS = [
         ...原内容省略...
         'userprofile',
    ]
    

    最后将其路由添加到项目的urls.py中:

    urlpatterns = [
        ...原内容省略...
        path('userprofile/', include('userprofile.urls', namespace='userprofile')),
    ]
    

    以上就是注册一个app的基本流程。接下来我们在userprofile应用中新建一个用户模型UserProfile

    class UserProfile(AbstractUser):
        avatar = models.ImageField(upload_to="avatar/%Y%m%d/", default="avatar/20210705/default.png", blank=True)
    
        class Meta:
            ordering = ['id']
    
        def __str__(self):
            return self.username
    

    我们自定义的用户模型继承自AbstractUser,事实上Django提供的User也是继承自AbstractUser。而AbstractUser还有一个父类AbstractBaseUser,区别在于前者已经定义了很多字段、实现了登录登出等基本功能,也就是说其实AbstractBaseUser才是真正的"抽象类"。因此,我们自定义的UserProfile其实已经继承了很多基本字段,我们只需添加头像字段即可。
    而头像字段使用到了ImageField字段类型,在执行makemigrations前需要安装依赖包:pillow。在Pycharm的Terminal终端窗口执行安装命令:

    pip install pillow
    

    而想要真正使用自定义的认证模型UserProfile,还需要在setting.py中添加下面内容,才能替换默认的User模型。

    AUTH_USER_MODEL = 'userprofile.UserProfile'
    

    最后执行如下命令来生成数据表:

    python manage.py makemigrations
    python manage.py migrate
    

    注意:使用这种方式创建自定义用户模型时,如果之前创建过用户或相应的数据表,在执行数据库迁移命令之前需要清空原数据,否则会报错。具体做法是删除所有应用下的migrations文件夹下除__init__.py外的所有文件,而博主在踩过坑后发现还需要删除db.sqlite3才能彻底清空原数据,一定要在删除干净后再执行数据库迁移命令!


    模型创建成功后,接下来开始真正实现用户的登录与注册。
    首先创建表单:

    class LoginForm(forms.Form):
        username = forms.CharField()
        password = forms.CharField()
    

    用户登录不需要对数据库进行任何改动,因此直接继承forms.Form就可以了。forms.Form需要手动配置每个字段,它适用于不与数据库进行直接交互的功能。
    然后创建视图与模板:

    def user_login(request):
        if request.method == "GET":
            login_form = LoginForm()
            context = {"login_form": login_form}
            return render(request, "userprofile/login.html", context)
        else:
            login_form = LoginForm(data=request.POST)
            if login_form.is_valid():
                data = login_form.cleaned_data
                user = authenticate(username=data['username'], password=data['password'])
                if user:
                    login(request, user)
                    return redirect("article:article-list")
                else:
                    return HttpResponse('账号密码输入有误,请重新输入!')
            else:
                context = {'obj': login_form, 'error': login_form.errors}
                return render(request, 'userprofile/login.html', context)
    
    • Form对象的主要任务就是验证数据,is_valid()Form实例的一个方法,用来做字段验证,当输入字段值合法时,它将返回True,同时将表单的数据存放到cleaned_data属性中。
    • authenticate()方法验证用户名称和密码是否匹配,如果是,则将这个用户数据返回。
    • login()方法实现用户登录,将用户数据保存在session中。

    注意:调用login()之前必须调用authenticate()成功认证登录用户。
    之所以用这么几行代码就实现了用户登录功能,是因为我们自定义的用户模型继承自AbstractUser,所以在功能上其实和Django内置的User是一样的。
    模板文件的核心代码如下:

    <form class='p-5' action="." method="post">
         {% csrf_token %}
        <span class="text-danger">{{ error }}</span>
        <div class="mb-3">
            <label for="username" class="form-label">账号</label>
            <input type="text" class="form-control" id="username" name="username">
        </div>
        <div class="mb-3">
            <label for="password" class="form-label">密码</label>
            <input type="password" class="form-control" id="password" name="password">
        </div>
        <div class="pt-3 pb-5">
            <button type="submit" class="btn btn-primary float-start mb-5">立即登录</button>
            <a class="text-decoration-none float-end text-danger mb-5 py-2" href="{% url 'userprofile:register' %}">没有账号?立即注册</a>
        </div>
    </form>
    

    最后在urls.py文件中加入该视图的路由即可:

    urlpatterns = [
        path('login/', user_login, name='login'),
    ]
    

    登陆页面的最终效果如下图所示:

    我们在header.html文件中加入登录的链接:

    <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
        <div class="container">
            ...原内容省略...
            <div class="collapse navbar-collapse justify-content-end">
                <ul class="navbar-nav">
                    {% if user.is_authenticated %}
                        <li class="nav-item dropdown">
                            <a class="nav-link dropdown-toggle p-0" href="#" id="navbarDropdownMenuLink" role="button"
                               data-bs-toggle="dropdown" aria-expanded="false">
                                <img src="{{ user.avatar.url }}" class="rounded-circle" style=" 40px;height: 40px;">
                            </a>
                            <ul class="dropdown-menu" aria-labelledby="navbarDropdownMenuLink">
                                <li><a class="dropdown-item" href="{% url 'userprofile:profile' %}"><i class="bi bi-person-fill"></i> 个人中心</a></li>
                                <li><a class="dropdown-item" href="{% url 'userprofile:logout' %}"><i class="bi bi-power"></i> 退出登录</a></li>
                            </ul>
                        </li>
                    {% else %}
                        <li class="nav-item">
                            <a class="nav-link active" aria-current="page" href="{% url 'userprofile:login' %}">登录</a>
                        </li>
                    {% endif %}
                </ul>
            </div>
        </div>
    </nav>
    

    user.is_authenticated用来判断用户是否登录,如果登录了则显示用户头像,并用下拉框显示其他功能,如果没有则显示登录链接。
    登出功能的实现非常简单,只需定义视图:

    def user_logout(request):
        logout(request)
        return redirect("article:article-list")
    

    然后添加路由即可:

    urlpatterns = [
        path('logout/', user_logout, name='logout'),
    ]
    

    注册功能的实现方法其实和登录功能差不多,整体流程都是先写表单,然后写视图和模板,最后添加路由。
    注册表单如下:

    class RegisterForm(forms.ModelForm):
        password = forms.CharField()
        password2 = forms.CharField()
    
        class Meta:
            model = UserProfile
            fields = ('username', 'email')
    
        def clean_password2(self):
            data = self.cleaned_data
            if data.get('password') == data.get('password2'):
                return data.get('password')
            else:
                return forms.ValidationError('两次输入的密码不一致,请重新输入!')
    

    注册表单需要对数据库进行操作,因此应该继承forms.ModelForm,可以自动生成模型中已有的字段。
    这里我们覆写了password字段,因为通常在注册时需要重复输入password来确保用户没有将密码输入错误,所以覆写掉它以便我们自己进行数据的验证工作。def clean_password2()中的内容便是在验证密码是否一致了。def clean_[字段]这种写法Django会自动调用,来对单个字段的数据进行验证清洗。
    覆写某字段之后,内部类class Meta中的定义对这个字段就没有效果了,所以fields不用包含password
    需要注意:

    • 验证密码一致性方法不能写def clean_password(),因为如果你不定义def clean_password2()方法,会导致password2中的数据被Django判定为无效数据从而清洗掉,从而password2属性不存在。最终导致两次密码输入始终会不一致,并且很难判断出错误原因。
    • POST中取值用的data.get('password')是一种稳妥的写法,即使用户没有输入密码也不会导致程序错误而跳出。前面章节提取POST数据我们用了data['password'],这种取值方式如果data中不包含password,Django会报错。另一种防止用户不输入密码就提交的方式是在表单中插入required属性。

    注册的视图函数如下:

    def user_register(request):
        if request.method == 'GET':
            register_form = RegisterForm()
            context = {'register_form': register_form}
            return render(request, 'userprofile/register.html', context)
        else:
            register_form = RegisterForm(data=request.POST)
            if register_form.is_valid():
                new_user = register_form.save(commit=False)
                new_user.set_password(register_form.cleaned_data['password'])
                new_user.save()
                return redirect("userprofile:login")
            else:
                context = {'obj': register_form, 'error': register_form.errors}
                return render(request, 'userprofile/register.html', context)
    

    注册的模板核心如下:

    <form class='p-5' action="." method="post">
        {% csrf_token %}
        <span class="text-danger">{{ error }}</span>
        <div class="input-group mb-4">
            <span class="input-group-text" id="basic-addon1"><i class="bi bi-person"></i></span>
            <input type="text" class="form-control" placeholder="用户名" name="username"
                   aria-describedby="basic-addon1" required="required">
        </div>
        <div class="input-group mb-4">
            <span class="input-group-text" id="basic-addon1"><i class="bi bi-envelope"></i></span>
            <input type="email" class="form-control" placeholder="邮箱" name="email"
                   aria-describedby="basic-addon1" required="required">
        </div>
        <div class="input-group mb-4">
            <span class="input-group-text" id="basic-addon1"><i class="bi bi-key"></i></span>
            <input type="password" class="form-control" placeholder="密码" name="password"
                   aria-describedby="basic-addon1" required="required">
        </div>
        <div class="input-group mb-4">
            <span class="input-group-text" id="basic-addon1"><i class="bi bi-key"></i></span>
            <input type="password" class="form-control" placeholder="确认密码" name="password2"
                   aria-describedby="basic-addon1" required="required">
        </div>
        <div class="d-grid gap-3">
            <button type="submit" class="btn btn-primary">立即注册</button>
        </div>
        <div class="mt-3 row">
            <div class="col-6 justify-content-start"></div>
            <div class="col-6 justify-content-end">
                <a class="text-decoration-none float-end text-secondary" href="{% url 'userprofile:login' %}">已有账号?</a>
            </div>
        </div>
    </form>
    

    最后将注册的路由添加到urls.py中即可。最终效果如下:


    忘记密码是很多用户经常遇到的问题,因此很多网站都会在登陆页面添加一个找回密码的功能,我们这里也实现通过邮件来找回密码的功能。Django内置其实已经实现了通过邮件来找回密码的功能,其主要步骤如下:

    • 向用户邮箱发送包含重置密码地址的邮件。邮件的地址需要动态生成,防止不怀好意的用户从中捣乱;
    • 向网站用户展示一条发送邮件成功的信息;
    • 用户点击邮箱中的地址后,转入重置密码的页面;
    • 向用户展示一条重置成功的信息。

    其上四个流程分别由PasswordResetViewPasswordResetDoneViewPasswordResetConfirmViewPasswordResetCompleteView四个视图完成,因此我们要做的其实就是为它们配置路由罢了。在项目的urls.py中添加如下内容:

    urlpatterns = [
        ...原内容省略...
        path('password_reset/', PasswordResetView.as_view(template_name='userprofile/password_reset_form.html',
                                                          email_template_name='userprofile/password_reset_email.html',),
             name='password_reset'),
        path('password_reset_done/', PasswordResetDoneView.as_view(template_name='userprofile/password_reset_done.html'),
             name='password_reset_done'),
        path('reset/<uidb64>/<token>/',
             PasswordResetConfirmView.as_view(template_name='userprofile/password_reset_confirm.html'),
             name='password_reset_confirm'),
        path('password_reset_complete/',
             PasswordResetCompleteView.as_view(template_name='userprofile/password_reset_complete.html'),
             name='password_reset_complete'),
    ]
    

    为什么要在每个视图后都跟着as_view()?这是因为它们都是基于类的视图,也就是说它们的本质是class,而括号内的template_nameemail_template_name其实都是传递给class的参数,具体可查看源码。事实上每一个视图对应的模板其实都有自带的(查看路径venv/Lib/site-packages/django/contrib/admin/templates/registration/,我们自定义的模板其实是根据自带模板改编的),也就是说配置完路由其实这个功能就已经实现了。我们之所以要自己编写模板,其实是为了和自己网站的风格相适应,而且最不可忍受的是自带模板竟然还有Django标志。我们自己编写的模板放在templateuserprofile文件夹下,每个模板的内容如下:
    password_reset_form.html

    {% load static %}
    <!DOCTYPE html>
    <html lang="zh-cn">
    <head>
        <meta charset="UTF-8">
        <title>找回密码</title>
        <link rel="stylesheet" href="{% static 'bootstrap/css/bootstrap.min.css' %}">
        <link rel="icon" href="{% static 'img/logo.png' %}">
    </head>
    <body>
        <div class="container m-5">
            <p>忘记密码?在下面输入你的电子邮箱地址,我们将会把设置新密码的操作步骤说明通过电子邮件发送给你。</p>
            <form method="post">
                {% csrf_token %}
                <fieldset>
                    <div class="pb-3 mb-3 border-bottom">
                        {{ form.email.errors }}
                        <label for="id_email">电子邮件地址:</label>
                        {{ form.email }}
                    </div>
                    <input class="btn btn-primary" type="submit" value="重设我的密码">
                </fieldset>
            </form>
        </div>
    </body>
    </html>
    

    password_reset_email.html

    {% autoescape off %}
    您收到这封邮件是因为您在请求重置您在网站{{ site_name }}上的用户帐户密码。
    
    请访问该页面并设置一个新密码:
    {% block reset_link %}
    {{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %}
    {% endblock %}
    提醒一下,您的用户名是:{{ user.get_username }}
    
    感谢您使用我们的网站
    
    {{ site_name }}团队
    
    {% endautoescape %}
    

    password_reset_done.html

    {% load static %}
    <!DOCTYPE html>
    <html lang="zh-cn">
    <head>
        <meta charset="UTF-8">
        <title>找回密码</title>
        <link rel="stylesheet" href="{% static 'bootstrap/css/bootstrap.min.css' %}">
        <link rel="icon" href="{% static 'img/logo.png' %}">
    </head>
    <body>
    <div class="container m-5 text-center">
    <p>如果你所输入的电子邮箱存在对应的用户,我们将通过电子邮件向你发送设置密码的操作步骤说明。你应该很快就会收到。</p>
    
    <p>如果你没有收到电子邮件,请检查输入的是你注册的电子邮箱地址。另外,也请检查你的垃圾邮件文件夹。</p>
    </div>
    </body>
    </html>
    

    password_reset_confirm.html

    {% load static %}
    <!DOCTYPE html>
    <html lang="zh-cn">
    <head>
        <meta charset="UTF-8">
        <title>找回密码</title>
        <link rel="stylesheet" href="{% static 'bootstrap/css/bootstrap.min.css' %}">
        <link rel="icon" href="{% static 'img/logo.png' %}">
    </head>
    <body>
    <div class="container">
        <p class="text-center mt-5">请输入新密码两次,以便我们验证您键入的密码是否正确。</p>
        <div class="col-md-4 col-sm-6 border offset-md-4 offset-sm-6 p-5 mt-5 bg-light">
            {% if validlink %}
                <form method="post">{% csrf_token %}
                    <fieldset>
                        <input class="visually-hidden" autocomplete="username" value="{{ form.user.get_username }}">
                        <div class="mb-3">
                            {{ form.new_password1.errors }}
                            <label class="form-label" for="id_new_password1">新密码:</label>
                            {{ form.new_password1 }}
                        </div>
                        <div class="mb-3">
                            {{ form.new_password2.errors }}
                            <label class="form-label" for="id_new_password2">确认密码:</label>
                            {{ form.new_password2 }}
                        </div>
                        <div class="d-grid">
                            <input class="btn btn-primary mt-3" type="submit" value="重置密码">
                        </div>
                    </fieldset>
                </form>
    
            {% else %}
    
                <p>密码重置链接无效,可能是因为它已被使用。请重新设置密码。</p>
    
            {% endif %}
        </div>
    </div>
    <script src="https://cdn.bootcss.com/jquery/3.3.1/jquery.min.js"></script>
    <script>
        $('#id_new_password1').addClass('form-control');
        $('#id_new_password2').addClass('form-control');
    </script>
    </body>
    </html>
    

    password_reset_complete.html

    {% load static %}
    <!DOCTYPE html>
    <html lang="zh-cn">
    <head>
        <meta charset="UTF-8">
        <title>找回密码</title>
        <link rel="stylesheet" href="{% static 'bootstrap/css/bootstrap.min.css' %}">
        <link rel="icon" href="{% static 'img/logo.png' %}">
    </head>
    <body>
    <div class="container m-5">
    <p>你的密码己经重置完成,现在你可以继续进行登录。</p>
    
    <p><a href="{% url 'userprofile:login' %}">登录</a></p>
    
    </div>
    </body>
    </html>
    

    至此,密码找回功能就算基本完成了。
    注意:如果要使用Django内置的通过邮箱来找回密码的功能(如上文),则路由配置一定要写在项目目录的urls.py中,而模板文件则没有要求。如果路由写在app中则会报错,博主踩过这个坑并且试了很多办法都没解决(本来想放在userprofile中,最后屈服了)。因此,切记路由要放在项目目录下,则基本没有什么问题,只要改改模板文件就可以了。


    7.文章增改与个人中心

    在上一篇中,我们已经实现了文章列表和详情页面,但当时我们调试用的数据是直接从后台输入的,因此本节我们继续完善文章的创作、修改、删除等功能。
    我们首先增加一个文章创作功能。到目前为止,想必我们对于增加功能的流程已经非常熟悉了,其实就是编写视图和模板(根据情况,有时需要编写表单和创建模型。一般来说,需要和数据库交互的都需要通过表单),然后添加路由就可以了。
    文章创作视图函数如下:

    @login_required(login_url='userprofile:login')
    def article_create(request):
        if request.method == "GET":
            article_post_form = ArticlePostForm()
            context = {"article_post_form": article_post_form}
            return render(request, "article/create.html", context)
        else:
            article_post_form = ArticlePostForm(data=request.POST)
            if article_post_form.is_valid():
                new_article = article_post_form.save(commit=False)
                new_article.author = request.user
                new_article.save()
                return redirect("article:article-list")
            else:
                return HttpResponse("表单填写有误,请重新填写!")
    

    首先,我们对于文章创作要求用户必须登录,参数login_url指明了登录链接,当用户未登录时会自动跳转到登录页面。其次,当文章发布成功后,我们将重定向到首页,即文章列表页面,展示在第一位的就是刚刚发布的文章,这是因为我们创建模型时定义的排序方式是按照创建时间倒序排列。
    文章创作模板如下:

    {% extends "base.html" %}
    {% block title %}创作{% endblock %}
    {% block content %}
        <div class="container">
            <form method="post" action="." class="mt-4">
                {% csrf_token %}
                <div class="mb-3">
                    <label for="title" class="form-label">文章标题</label>
                    <input type="text" class="form-control" id="title" name="title">
                </div>
                <div class="mb-3">
                    <label for="body" class="form-label">文章正文</label>
                    <textarea type="text" class="form-control" rows="12" id="body" name="body"></textarea>
                </div>
                <button type="submit" class="btn btn-primary">发布</button>
            </form>
        </div>
    {% endblock %}
    

    然后添加路由就实现了文章创作功能。
    接下来我们实现文章修改、删除功能。我们可以在现有的文章详情页面添加文章修改、删除功能,我们先看修改后的文章详情模板文件:

    <div class="pt-4">
        <h1>{{ article.title }}</h1>
        <small class="text-secondary"><i class="bi bi-person"></i> 作者 {{ article.author }}</small>
        <small class="text-secondary mx-4"><i class="bi bi-clock"></i> 发表于 {{ article.created }}</small>
        {% if article.author.username == user.username%}
        <a href="#" class="text-decoration-none float-end text-danger ms-3"
           onclick="if(confirm('确定要删除这篇文章吗?')) location.href='{% url "article:article-delete" article.id %}'">删除</a>
        <a href="{% url "article:article-update" article.id %}" class="text-decoration-none float-end">修改</a>
        {% endif %}
    </div>
    <div class="mt-2 border-top py-2">
        {{ article.body|safe }}
    </div>
    

    可以看到我们在模板中用if语句添加了几行代码,在if语句中我们判断的是文章作者与当前用户的username是否一致,从而决定用户是否有权修改这篇文章。只有当用户就是作者本人时,删除和修改链接才会显示出来。当删除文章时,我们会弹出一个确认框以提醒用户是否确认删除文章,从而防止用户手抖误删。
    文章修改、删除的视图函数如下:

    def article_delete(request, id):
        if request.method == "GET":
            article = ArticlePost.objects.get(id=id)
            if article.author.id == request.user.id:
                article.delete()
                return redirect("article:article-list")
            else:
                return HttpResponse('你没有权限删除这篇文章!')
    
    
    def article_update(request, id):
        article = ArticlePost.objects.get(id=id)
        if request.method == "GET":
            context = {"article": article}
            return render(request, "article/update.html", context)
        else:
            article_post_form = ArticlePostForm(data=request.POST)
            if article_post_form.is_valid():
                if article.author.id == request.user.id:
                    article.title = request.POST['title']
                    article.body = request.POST['body']
                    article.save()
                    return redirect("article:article-detail", id=id)
                else:
                    return HttpResponse('你无权修改这篇文章!')
            else:
                return HttpResponse("表单内容有误,请重新填写!")
    

    可以看到,在视图中我们再次确认了用户是否有权修改或删除文章,虽然在模板中我们已经初步确认了用户权限,但是出于安全考虑在后端再次进行确认还是很有必要的。
    文章修改的模板如下:

    {% extends "base.html" %}
    {% block title %}文章修改{% endblock %}
    {% block content %}
        <div class="container">
            <form method="post" action="." class="mt-4">
                {% csrf_token %}
                <div class="mb-3">
                    <label for="title" class="form-label">文章标题</label>
                    <input type="text" class="form-control" id="title" name="title" value="{{ article.title }}">
                </div>
                <div class="mb-3">
                    <label for="body" class="form-label">文章正文</label>
                    <textarea type="text" class="form-control" rows="12" id="body" name="body">{{ article.body }}</textarea>
                </div>
                <button type="submit" class="btn btn-primary">提交修改</button>
            </form>
        </div>
    {% endblock %}
    

    不难看出,文章修改模板其实和文章创作模板差不多,区别就在于文章修改模板预填了原来的文章内容。
    最后将以上功能添加到路由中即可:

    app_name = 'article'
    
    urlpatterns = [
        path('article-list/', article_list, name='article-list'),
        path('article-detail/<int:id>/', article_detail, name='article-detail'),
        path('article-create/', article_create, name='article-create'),
        path('article-delete/<int:id>/', article_delete, name='article-delete'),
        path('article-update/<int:id>/', article_update, name='article-update'),
    ]
    

    至此,关于文章的基本功能就算完成了,更多功能(分页、搜索、点赞、评论等)我们后面慢慢完善。


    在上一节中,我们已经实现了登录注册功能,这一节我们在此基础上添加个人中心功能。个人中心目前设计的主要功能就是展示一些信息以及更换头像。其中,主要功能我认为是更换头像,虽然用户注册时会使用默认头像,但是默认头像显然不能满足个性化需求。
    由于需要修改用户头像,因此我们要先建一个表单,如下:

    class ProfileForm(forms.ModelForm):
        class Meta:
            model = UserProfile
            fields = ('avatar',)
    

    个人中心的视图函数如下:

    def user_profile(request):
        if request.method == "GET":
            articles = ArticlePost.objects.filter(author_id=request.user.id)
            context = {'articles': articles}
            return render(request, 'userprofile/profile.html', context)
        else:
            profile = UserProfile.objects.get(id=request.user.id)
            profile_form = ProfileForm(request.POST, request.FILES)
            if profile_form.is_valid():
                profile_form_data = profile_form.cleaned_data
                if 'avatar' in request.FILES:
                    profile.avatar = profile_form_data['avatar']
                profile.save()
                return redirect('userprofile:profile')
            else:
                return HttpResponse('表单有误,请重新填写!')
    

    GET请求其实就是展示一些数据,主要是用户发表过的文章,而用户的一些基本信息其实不必传递,因为用户一旦登录这些基本信息就保存在session中了,模板页面中可以直接通过user访问。POST请求其实就是更换用户头像,表单上传的文件通过request.FILES进行访问。
    个人中心的模板如下:

    {% extends 'base.html' %}
    {% block title %}个人中心{% endblock %}
    {% block style %}
        .box{
            position: relative;
            overflow: hidden;
        }
        .box img{
             100%;
            height: auto;
        }
        .box .box-content{
             100%;
            height: 100%;
            position: absolute;
            top: 0;
            left: 0;
            color: #fff;
            text-align: center;
            padding: 40% 20px;
            background: rgba(0,0,0,0.6);
            transform: rotate(-90deg);
            transform-origin: left top 0;
            transition: all 0.50s ease 0s;
        }
        .box .read{
            font-size: 20px;
            font-weight: bold;
            color: #fff;
            display: block;
            letter-spacing:2px;
            transform: rotate(180deg);
            transform-origin: right top 0;
            transition: all 0.3s ease-in-out 0.2s;
        }
        .box .read:hover{
            color: #e8802e;
            text-decoration: none;
        }
        .box:hover .box-content,
        .box:hover .read {
            transform:rotate(0deg);
        }
        @media screen and (max- 990px){
            .box{ margin-bottom:20px; }
        }
        @media screen and (max- 359px){
            .box .box-content{ padding: 10% 20px; }
        }
    {% endblock %}
    {% block content %}
        <div class="container">
            <form action="." method="post" enctype="multipart/form-data" class="visually-hidden">
                {% csrf_token %}
                <input class="form-control" type="file" name="avatar" id="upload_avatar">
                <button type="submit" id="submit"></button>
            </form>
            <div class="row shadow mt-4 py-3">
                <div class="col-2">
                    <div class="box">
                        <img src="{{ user.avatar.url }}" class="img-thumbnail mx-auto d-block" alt="头像">
                        <div class="box-content">
                            <span class="read" onclick="x()">更换头像</span>
                        </div>
                    </div>
                </div>
                <div class="col-10">
                    <h1>{{ user.username }}</h1>
                    <p><i class="bi bi-calendar-check-fill"></i> 入园时间:{{ user.date_joined }}</p>
                    <p><i class="bi bi-calendar-check"></i> 上次登录:{{ user.last_login }}</p>
                    <p><i class="bi bi-envelope-fill"></i> 注册邮箱:{{ user.email }}</p>
                </div>
            </div>
            <div class="row mt-4">
                <div class="col-8 shadow">
                    {% for article in articles %}
                        <div class="row">
                            <div class="card border-0 mt-3 h-250">
                                <div class="card-header">
                                    <h5>{{ article.title }}</h5>
                                    <small class="text-secondary"><i class="bi bi-clock"></i> 发表于 {{ article.created }}
                                    </small>
                                </div>
                                <div class="card-body">
                                    <p class="card-text">{{ article.body|slice:'100' }}</p>
                                    <!-- slice:'100'是Django的过滤器语法,表示取出正文的前100个字符,避免摘要太长 -->
                                    <a href="{% url 'article:article-detail' article.id %}" class="btn btn-primary">阅读本文</a>
                                </div>
                            </div>
                        </div>
                    {% endfor %}
                </div>
                <div class="col-3 offset-1 shadow">
    
                </div>
            </div>
        </div>
        <script>
            function x() {
                const $input = $('#upload_avatar');
                $input.click();
                $input.change(function () {
                    //如果value不为空,调用文件加载方法
                    if($(this).val() !== ""){
                        $("#submit").click();
                    }
                })
            }
        </script>
    {% endblock content %}
    

    内容看起来很多,其实一大部分都是css代码(我更改了base.html,增加了style块用于子模板添加独有的样式),用来实现鼠标移到头像上则显示遮罩层过渡动画与更换头像的链接。通过js代码不难看出,其实更换头像的本质是通过隐藏的表单来实现的。
    注意:form表单要上传文件,必须设置enctype="multipart/form-data",否则文件无法上传且不会报错,难以察觉。
    上面很多功能没有写添加到header.html中,当然这部分也不难,这里就不再赘述了。
    个人中心的效果图如下:

    8.文章分页与搜索排序

    对于绝大多数网站而言,分页都是必须的操作,因为大量的结果展示在同一页面,不仅造成页面冗长不便于阅读,而且并不美观,博客网站同样如此。我们采用Django内置的分页模块——Paginator来实现博客文章分页功能(自己实现还是很困难的,emmm...)。
    我们修改文章列表的视图:

    from django.core.paginator import Paginator
    
    def article_list(request):
        article_list = ArticlePost.objects.all()
    
        paginator = Paginator(article_list, 1)  # 每页显示 1 篇文章
        page = request.GET.get('page')  # 获取 url 中的页码
        articles = paginator.get_page(page)  # 将导航对象相应的页码内容返回给 articles
    
        context = { 'articles': articles }
        return render(request, 'article/list.html', context)
    

    从视图函数中可以看出,要实现完整的分页功能,我们至少需要传递page参数以获取对应的页码。这里我们介绍一种通过url地址传递参数的方法:即在url的末尾附上?key=value的键值对,视图中就可以通过request.GET.get('key')来查询value的值。
    我们在模板list.html中添加分页控制按钮:

    <nav>
        <ul class="pagination">
            {% if articles.has_previous %}
                <li class="page-item">
                    <a class="page-link" href="?page={{ articles.previous_page_number }}" aria-label="Previous">
                        <span aria-hidden="true">&laquo;</span>
                    </a>
                </li>
            {% endif %}
            {% if articles.previous_page_number > 1 %}
                <li class="page-item">
                    <a class="page-link" href="#" aria-label="Previous">
                        <span aria-hidden="true">...</span>
                    </a>
                </li>
            {% endif %}
            {% if articles.has_previous %}
                <li class="page-item">
                    <a class="page-link" href="?page={{ articles.previous_page_number }}" aria-label="Previous">
                        <span aria-hidden="true">{{ articles.previous_page_number }}</span>
                    </a>
                </li>
            {% endif %}
            <li class="page-item active"><a class="page-link" href="#">{{ articles.number }}</a></li>
            {% if articles.has_next %}
                <li class="page-item">
                    <a class="page-link" href="?page={{ articles.next_page_number }}" aria-label="Next">
                        <span aria-hidden="true">{{ articles.next_page_number }}</span>
                    </a>
                </li>
            {% endif %}
            {% widthratio articles.number 1 -1 as num %}
            {% if articles.paginator.num_pages|add:num > 1 %}
                <li class="page-item">
                    <a class="page-link" href="#" aria-label="Next">
                        <span aria-hidden="true">...</span>
                    </a>
                </li>
            {% endif %}
            {% if articles.has_next %}
                <li class="page-item">
                    <a class="page-link" href="?page={{ articles.next_page_number }}" aria-label="Next">
                        <span aria-hidden="true">&raquo;</span>
                    </a>
                </li>
            {% endif %}
        </ul>
    </nav>
    

    在上述模板中,articles是视图函数传递过去的Paginator对象,has_previoushas_next等是对象的方法名,其含义不难理解。widthratio是Django模板中的一种用于运算的标签,它需要三个参数,其返回结果是参数1/参数2*参数3,利用它可以巧妙实现乘除法,文中就是利用它将articles.number变成负数,然后和articles.paginator.num_pages相加,从而获取当前页面后面的剩余页面数量。
    接下来我们实现文章的搜索和排序(最新、最热)功能,最热文章的排序就是根据浏览量进行排序,为此我们需要先修改ArticlePost模型:

    class ArticlePost(models.Model):
        ...
        total_views = models.PositiveIntegerField(default=0)
        ...
    

    然后执行数据库迁移命令,这里就不多介绍数据库迁移命令的写法了,到现在为止想必各位已经熟悉了。有了浏览量字段后就需要在模板中展示出来,这也不多介绍了。
    我们对浏览量计数的方法很简单,就是每调用一次article_detail方法就给对应文章的浏览量加一。

    def article_detail(request, id):
        article = ArticlePost.objects.get(id=id)
        article.total_views += 1
        article.save(update_fields=['total_views'])
        ...
    

    update_fields=[]指定了数据库只更新total_views字段,优化了执行效率。
    文章的搜索、排序的实现方法其实和分页功能差不多,其实都是通过url地址传递参数到视图函数中以获取对应的内容,多个参数用&连接。修改后的视图函数如下:

    def article_list(request):
        search = request.GET.get('search')
        order = request.GET.get('order')
        if search:
            all_articles = ArticlePost.objects.filter(Q(title__icontains=search) | Q(body__icontains=search))
        else:
            search = ''
            all_articles = ArticlePost.objects.all()
        if order == 'total_views':
            all_articles = all_articles.order_by('-total_views')
        paginator = Paginator(all_articles, 1)
        page_index = request.GET.get('page')
        articles = paginator.get_page(page_index)
        context = {'articles': articles, 'order': order, 'search': search}  # 传递给模板的上下文
        return render(request, "article/list.html", context)  # render函数的作用是结合模板和上下文,并返回渲染后的HttpResponse对象
    

    文章搜索功能是通过Model.objects.filter(**kwargs)来实现的,它可以返回与给定参数匹配的部分对象。而需要联合查询时就要用到Q对象,例如Q(title__icontains=search)意思就是在查询模型的title字段时返回包含search(不区分大小写)的对象。多个Q对象用管道符|隔开,就达到了联合查询的目的。

    注意:当用户没有搜索内容时要返回search = '',因为如果用户没有搜索操作,则search = request.GET.get('search')会使得search = None,而这个值传递到模板中会错误地转换成"None"字符串!等同于用户在搜索“None”关键字,这明显是错误的。

    排序功能是通过order_by()实现的,该方法指定对象如何进行排序(我们创建的模型默认按照时间倒序排列,因此最新文章的排序不需要进行任何操作)。修改后的模型中有total_views这个整数字段,因此‘total_views’为正序,‘-total_views’为逆序。之所以把order也传递到模板中,是因为文章需要翻页,而order就是给模板一个标识,提醒模板下一页应该如何排序。
    搜索功能的模板我们放在header.html中,更加醒目。

    <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
        <div class="container">
            ...
            <form class="d-flex" action="{% url 'article:article-list' %}?">
                <div class="input-group">
                    <input class="form-control" type="search" placeholder="搜索文章..." value="{{ search }}" name="search" aria-describedby="search" required>
                    <button class="input-group-text" type="submit" id="search"><i class="bi bi-search"></i></button>
                </div>
            </form>
            ...
        </div>
    </nav>
    

    注意:我们是通过GET请求的url来传递参数,因此form中不能加method="POST",其默认方法为GET
    最新、最热排序的模板我们加在list.html中,即文章列表页面。

    {% extends "base.html" %}
    {% block title %}首页{% endblock %}
    {% block content %}
        <div class="container">
            <nav class="bg-light border pt-2 px-3 mt-4">
                <ol class="breadcrumb">
                    <li class="breadcrumb-item"><a href="{% url 'article:article-list' %}?search={{ search }}">最新</a></li>
                    <li class="breadcrumb-item"><a href="{% url 'article:article-list' %}?search={{ search }}&order=total_views">最热</a></li>
                </ol>
            </nav>
            {% if search %}
                <p class="mt-4"><span class="text-danger">“{{ search }}”</span>的搜索结果如下:</p>
            {% endif %}
            ...
            
        </div>
    {% endblock %}
    

    分页功能的href也需要修改,需要添加searchorder两个参数,如下例所示:

    {% if articles.has_previous %}
        <li class="page-item">
            <a class="page-link" href="?page={{ articles.previous_page_number }}&search={{ search }}&order={{ order }}" aria-label="Previous">
                <span aria-hidden="true">&laquo;</span>
            </a>
        </li>
    {% endif %}
    

    最终效果图如下所示:

    9.文章目录与发表评论

    在上篇中,我们已经为博文支持了Markdown语法,现在我们为其添加目录功能。
    修改文章详情视图:

    def article_detail(request, id):
        ...
        md = markdown.Markdown(
            extensions=[
                'markdown.extensions.extra',  # 包含 缩写、表格等常用扩展
                'markdown.extensions.codehilite',  # 语法高亮扩展
                'markdown.extensions.toc',  # 目录扩展
            ])
        article.body = md.convert(article.body)
        context = {'article': article, 'toc': md.toc}
        return render(request, "article/detail.html", context)
    

    我们仅仅是将markdown.extensions.toc扩展添加了进去。为了将目录插入到页面的任何一个位置,我们先将Markdown类赋值给一个临时变量md,然后用convert()方法将正文渲染为html页面,然后通过md.toc将目录传递给模板。
    修改文章详情模板:

    {% extends "base.html" %}
    {% block title %}文章详情{% endblock %}
    {% block content %}
        <div class="container">
            <div class="row">
                <div class="col-9 mt-4">
                    ...
                </div>
                <div class="col-3 mt-4">
                    <div class="shadow p-4">
                        <h1 class="text-center">目录</h1>
                        <div class="border-top pt-2">{{ toc|safe }}</div>
                    </div>
                </div>
            </div>
        </div>
        ...
    {% endblock %}
    

    我们重新布局了页面内容,将博客正文放到col-9的容器中,将目录放到右侧col-3的容器中。
    注意:toc需要|safe标签才能正确渲染,具体原因在上篇添加Markdown支持的时候阐述过。


    评论功能是一个独立的模块,我们首先要为其新建一个应用:

    python manage.py startapp comment
    

    然后在settings.py中注册应用:

    INSTALLED_APPS = [
        ...
        'comment',
    ]
    

    最后注册到根路由中:

    urlpatterns = [
        ...
        path('comment/', include('comment.urls', namespace='comment')),
    ]
    

    以上就是新建一个app的流程。下面我们实现评论模块的核心功能。
    首先编写评论的模型:

    class Comment(models.Model):
        article = models.ForeignKey(ArticlePost, on_delete=models.CASCADE)
        user = models.ForeignKey(UserProfile, on_delete=models.CASCADE)
        body = models.TextField()
        created = models.DateTimeField(auto_now_add=True)
    
        class Meta:
            ordering = ('created',)
    
        def __str__(self):
            return self.body[:20]
    

    该模型有两个外键,分别是ArticlePostUserProfile,这使我想起了学数据库时的学生选课表(emmm...)。
    注意:每次新建、修改模型后,都必须执行数据库迁移才能生效。
    用户提交评论需要用到表单,因此我们新建一个表单类:

    class CommentForm(forms.ModelForm):
        class Meta:
            model = Comment
            fields = ['body']
    

    然后我们新建路由文件

    app_name = 'comment'
    
    urlpatterns = [
        path('comment_post/<int:article_id>/', comment_post, name='comment_post'),
    ]
    

    再编写视图:

    @login_required(login_url='userprofile:login')
    def comment_post(request, article_id):
        article = get_object_or_404(ArticlePost, id=article_id)
        if request.method == 'POST':
            comment_form = CommentForm(data=request.POST)
            if comment_form.is_valid():
                new_comment = comment_form.save(commit=False)
                new_comment.article = article
                new_comment.user = request.user
                new_comment.save()
                return redirect(article)
            else:
                return HttpResponse('表单内容有误,请重新填写!')
        else:
            return HttpResponse('发表评论仅接受POST请求!')
    
    • get_object_or_404()Model.objects.get()的功能基本是相同的,区别是在生产环境下,如果用户请求一个不存在的对象时,后者会返回Error 500(服务器内部错误),而前者会返回Error 404。相比之下,返回404错误更加的准确。
    • redirect()返回到一个适当的url中:即用户发送评论后,重新定向到文章详情页面。当其参数是一个Model对象时,会自动调用这个Model对象的get_absolute_url()方法。因此我们接下来马上修改 ArticlePost模型:
    class ArticlePost(models.Model):
        ...
        # 通过reverse()方法返回文章详情页面的url,实现了路由重定向
        def get_absolute_url(self):
            return reverse('article:article-detail', args=[self.id])
    

    评论模块需要在文章详情页面展示,因此接下来修改文章详情的视图和模板。
    首先修改文章详情视图:

    def article_detail(request, id):
        ...
        comments = Comment.objects.filter(article=id)
        context = {'article': article, 'toc': md.toc, 'comments': comments}
        return render(request, "article/detail.html", context)
    

    filter()可以取出多个满足条件的对象,而get()只能取出1个,注意区分使用。
    然后修改文章详情模板:

    {% extends "base.html" %}
    {% block title %}文章详情{% endblock %}
    {% block content %}
        <div class="container">
            <div class="row">
                <div class="col-9 mt-4">
                    <div class="shadow p-4">
                        ...
                        <p><span class="fw-bolder text-warning">{{ comments.count }}</span> 评论</p>
                        {% if user.is_authenticated %}
                            <form action="{% url 'comment:comment_post' article.id %}" method="POST">
                                {% csrf_token %}
                                <div class="input-group">
                                    <textarea class="form-control me-3" name="body" aria-label="With textarea"
                                              required></textarea>
                                    <button type="submit" class="input-group-text btn btn-primary">发表评论</button>
                                </div>
                            </form>
                        {% else %}
                            <h5 class="text-center">请<a href="{% url 'userprofile:login' %}" class="text-decoration-none">【登录】</a>后发表评论!
                            </h5>
                        {% endif %}
                        <div class="border p-4 mt-3 bg-light">
                            {% for comment in comments %}
                                <div class="row border-bottom mb-3">
                                    <div class="col-1">
                                        <img src="{{ comment.user.avatar.url }}" alt="用户头像" class="img-fluid">
                                    </div>
                                    <div class="col-11">
                                        <span class="text-info fw-bolder">{{ comment.user }}</span>
                                        <span class="float-end text-secondary">{{ comment.created|date:"Y-m-d H:i:s" }}</span>
                                        <p>{{ comment.body }}</p>
                                    </div>
                                </div>
                            {% endfor %}
                        </div>
                    </div>
                </div>
                <div class="col-3 mt-4">
                    ....
                </div>
            </div>
        </div>
        ...
    {% endblock %}
    
    • comments.count是模板对象中内置的方法,对包含的元素进行计数。
    • |date:"Y-m-d H:i :s"管道符你已经很熟悉了,用于给对象“粘贴”某些属性或功能。这里用于格式化日期的显示方式。

    最终效果如下图:

    10.文章栏目标签标题图

    文章栏目既方便博主对文章进行分类归档,也方便用户有针对性的阅读。要实现栏目功能其实不难,无非就是新建一个栏目模型,再以外键形式关联到文章模型。
    文章标签其实和文章栏目差不多,不同点在于一篇文章可以有多个标签,但只能有一个栏目。这里我们采用一个实现了标签功能的优秀的三方库:django-taggit(具体安装不再赘述,安装完记得在settings.py中注册app——taggit),利用该库进行快速开发。
    标题图的添加是考虑到有时一图胜千言,通过图片能够快速了解文章内容。前面我们已经介绍过用户头像了,标题图其实也差不多,只是我们增加了对图片进行缩放等处理。
    首先修改article/modles.py文件:

    class ArticleColumn(models.Model):
        title = models.CharField(max_length=50, blank=True)
        created = models.DateTimeField(default=timezone.now)
    
        def __str__(self):
            return self.title
    
    
    class ArticlePost(models.Model):
        ...
        column = models.ForeignKey(ArticleColumn, on_delete=models.CASCADE, blank=True, null=True)
        tags = TaggableManager(blank=True)
        avatar = models.ImageField(upload_to='article/%Y%m%d/', default="article/20210716/default.jpeg", blank=True)
        ...
        def save(self, *args, **kwargs):
            super(ArticlePost, self).save(*args, **kwargs)
            if self.avatar and not kwargs.get('update_fields'):
                image = Image.open(self.avatar)
                image = image.resize((400, 225), Image.ANTIALIAS)
                image.save(self.avatar.path)
    

    首先我们增加了一个栏目模型——ArticleColumn,该模型的字段很简单,因此不过多介绍。对于文章模型,我们不仅添加了三个字段(tags有点特殊:因为标签引用的不是内置字段,而是库中的TaggableManager,它是处理多对多关系的管理器),还定义了save()方法。

    • save()model内置的方法,它会在model实例每次保存时调用。我们这里改写它,将处理图片的逻辑加入进去。
    • super(ArticlePost, self).save(*args, **kwargs)的作用是调用父类中原有的save()方法,即将model中的字段数据保存到数据库中。因为图片处理是基于已经保存的图片的,所以这句一定要在处理图片之前执行,否则会得到找不到原始图片的错误。
    • not kwargs.get('update_fields')是为了排除掉统计浏览量调用的save(),免得每次用户进入文章详情页面都要处理标题图,因为我们在article_detail()视图中为了统计浏览量而调用了save(update_fields=['total_views'])
    • Pillow库负责处理图片,将新图片的宽高设置为(400,225),最后用新图片将原始图片覆盖掉。Image.ANTIALIAS表示缩放采用平滑滤波。

    模型修改完毕,记住要执行数据迁移才能生效。
    然后我们在article/admin.py中将栏目模型注册到后台,并在后台添加几个栏目,然后随机找几篇文章设置不同的栏目以便后续测试。

    admin.site.register(ArticleColumn)
    

    既然我们已经在文章模型中添加了新字段,那么接下来文章创作和文章修改这两个功能也要做些更改,要将这几个新字段添加进去。
    由于新文章是通过表单上传到数据库中的,因此我们先修改文章创作的表单类:

    class ArticlePostForm(forms.ModelForm):
        class Meta:
            model = ArticlePost  # 指明数据模型的来源
            fields = ('title', 'body', 'tags', 'avatar')  # 定义表单包含的字段
    

    我们在表单中增加了tagsavatar两个字段。
    然后我们修改文章创作视图:

    def article_create(request):
        if request.method == "GET":
            article_post_form = ArticlePostForm()
            columns = ArticleColumn.objects.all()
            context = {"article_post_form": article_post_form, 'columns': columns}
            return render(request, "article/create.html", context)
        else:
            article_post_form = ArticlePostForm(request.POST, request.FILES)
            if article_post_form.is_valid():
                new_article = article_post_form.save(commit=False)
                new_article.author = request.user
                if request.POST['column'] != 'none':
                    new_article.column = ArticleColumn.objects.get(id=request.POST['column'])
                if 'avatar' in request.FILES:
                    new_article.avatar = request.FILES['avatar']
                new_article.save()
                article_post_form.save_m2m()
                return redirect("article:article-list")
            else:
                return HttpResponse("表单填写有误,请重新填写!")
    

    修改之处主要有以下几点:

    • GET中增加了栏目的上下文,以便模板使用,用户只需在下框中选择即可。
    • 标题图是文件,应该在request.FILES里获取它,而不是request.POST
    • 对文章栏目和标题图进行判断,通过save_m2m()保存文章和标签的关系。

    最后我们来看文章创作的模板:

    <form method="post" action="." enctype="multipart/form-data" class="mt-4">
        ...
        <div class="mb-3">
            <label for="avatar">文章标题图</label>
            <input type="file" class="form-control" name="avatar" id="avatar">
        </div>
        <div class="mb-3">
            <label for="column" class="form-label">文章栏目</label>
            <select class="form-select" id="column" name="column">
                <option selected>请选择文章栏目...</option>
                {% for column in columns %}
                    <option value="{{ column.id }}">{{ column.title }}</option>
                {% endfor %}
            </select>
        </div>
        <div class="mb-3">
            <label for="tags" class="form-label">文章标签</label>
            <input type="text" class="form-control" id="tags" name="tags" placeholder="文章标签请用英文逗号分隔">
        </div>
        ...
    </form>
    
    • 为了上传标题图,我们需要对form添加enctype="multipart/form-data"属性,该属性的含义是表单提交时不对字符编码。
    • <select>是表单的下拉框选择组件,在这个组件中循环列出所有的栏目数据,我们将value属性设置为栏目的id值。

    文章修改其实和文章创作差不多,主要就是需要将原数据返回到表单中方便修改。
    其视图函数如下:

    def article_update(request, id):
        article = ArticlePost.objects.get(id=id)
        if request.method == "GET":
            ...
            context = {"article": article, 'article_form': article_form, 'columns': columns}
            return render(request, "article/update.html", context)
        else:
            article_post_form = ArticlePostForm(request.POST, request.FILES)
            if article_post_form.is_valid():
                if article.author.id == request.user.id:
                    ...
                    article.tags.set(*request.POST.get('tags').split(','), clear=True)
                    article.save()
            ...
    

    tags.set()是库提供的接口,用于更新标签数据。
    文章修改的模板文件如下:

    <form method="post" action="." enctype="multipart/form-data" class="mt-4">
        ...
        <div class="mb-3">
            <label for="column" class="form-label">文章栏目</label>
            <select class="form-select" id="column" name="column">
                <option selected>请选择文章栏目...</option>
                {% for column in columns %}
                    <option value="{{ column.id }}" {% if column.id == article.column.id %}selected{% endif %}>{{ column.title }}</option>
                {% endfor %}
            </select>
        </div>
        <div class="mb-3">
            <label for="tags" class="form-label">文章标签</label>
            <input type="text" class="form-control" id="tags" name="tags" value="{{ article.tags.all|join:"," }}">
        </div>
        ...
    </form>
    

    与之前不同的是,我们在表单中判断了column.idarticle.column.id是否相等,如果相等则将其设置为默认值。而对于tags,由于视图传递过来的是一个set,因此我们通过|join:","将元素用英文逗号连接成字符串。

    至此,文章创作和文章修改的变更就差不多了。接下来就是展示文章标题图和栏目标签了。
    对于标题图,我们将其展示在文章列表页面,修改后的模板如下:

    <div class="card my-4 h-250">
        <div class="row g-0">
            <div class="col-4">
                <img src="{{ article.avatar.url }}" class="img-fluid rounded-start" alt="文章标题图">
            </div>
            <div class="col-8">
                <div class="card-header">
                    <h4>{{ article.title }}</h4>
                    <small class="text-secondary"><i class="bi bi-clock"></i> 发表于 {{ article.created }}</small>
                    <small class="text-secondary"><i class="bi bi-eye"></i> 阅读: {{ article.total_views }}
                    </small>
                </div>
                <div class="card-body">
                    <p class="card-text">{{ article.body|truncatechars:300 }}</p>
                    <!-- slice:'100'是Django的过滤器语法,表示取出正文的前100个字符,避免摘要太长 -->
                    <a href="{% url 'article:article-detail' article.id %}" class="btn btn-primary">阅读本文</a>
                </div>
            </div>
        </div>
    </div>
    

    具体效果如图:

    对于栏目和标签,我们将其展示在文章详情页面,修改后的模板如下:

    ...
    <p>
        {% if article.column %}
            <a href="{% url 'article:article-list' %}?column={{ article.column.title }}"
               class="btn btn-warning text-white me-2">{{ article.column.title }}</a>
        {% endif %}
        {% for tag in article.tags.all %}
            <a href="{% url 'article:article-list' %}?tag={{ tag }}"
               class="btn btn-info text-white me-2">{{ tag }}</a>
        {% endfor %}
    </p>
    ...
    

    具体效果如图:


    下面我们实现按照栏目和标签进行搜索的功能。
    首先修改文章列表的视图:

    def article_list(request):
        ...
        column = request.GET.get('column')
        tag = request.GET.get('tag')
        ...
        if column:
            all_articles = all_articles.filter(column__title=column)
        else:
            column = ''
        if tag:
            all_articles = all_articles.filter(tags__name__in=[tag])
        else:
            tag = ''
        ...
    

    然后修改模板中的分页按钮链接:

    <li class="page-item">
        <a class="page-link"
           href="?page={{ articles.previous_page_number }}&search={{ search }}&order={{ order }}&column={{ column }}&tag={{ tag }}"
           aria-label="Previous">
            <span aria-hidden="true">&laquo;</span>
        </a>
    </li>
    

    具体效果如下图:

  • 相关阅读:
    《.NET内存管理宝典 》(Pro .NET Memory Management) 阅读指南
    《.NET内存管理宝典 》(Pro .NET Memory Management) 阅读指南
    《.NET内存管理宝典 》(Pro .NET Memory Management) 阅读指南
    使用Jasmine和karma对传统js进行单元测试
    《.NET内存管理宝典 》(Pro .NET Memory Management) 阅读指南
    《.NET内存管理宝典 》(Pro .NET Memory Management) 阅读指南
    nginx 基于IP的多虚拟主机配置
    Shiro 框架的MD5加密算法实现原理
    项目实战:Qt+OSG三维点云引擎(支持原点,缩放,单独轴或者组合多轴拽拖旋转,支持导入点云文件)
    实用技巧:阿里云服务器建立公网物联网服务器(解决阿里云服务器端口,公网连接不上的问题)
  • 原文地址:https://www.cnblogs.com/marvin-wen/p/14988744.html
Copyright © 2020-2023  润新知