zoukankan      html  css  js  c++  java
  • 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>
    

    具体效果如下图:

  • 相关阅读:
    js的循环遍历
    实现div里的img图片水平垂直居中(五种方法)——转载好文
    layui 的 loading 左偏移
    小茴香windows常用软件清单
    Vue为同一个元素绑定不同的点击事件
    echarts 线图图例去掉圆圈等形状
    No phantomjs found in your PATH. Please install it! pyecharts导出图片报错 windows
    mysql多列一致性约束unique
    笔试题-LRU
    准确率和召回率
  • 原文地址:https://www.cnblogs.com/marvin-wen/p/14988744.html
Copyright © 2011-2022 走看看