Django 实战:做一个个人博客 · 第 5 / 8 篇(展开目录)

全系列用 Django 5.2 LTS 做一个个人博客:游客看文章,作者登录后写文章,管理员维护分类和账号。Django1—Django8 是文章序号,不是 Django 版本号。

  1. Django1:起步——安装、运行与项目结构
  2. Django2:配置与路由——一个请求在 Django 里怎么走
  3. Django3:模型与迁移——把博客的数据存进数据库
  4. Django4:ORM——查询、更新、统计与事务
  5. Django5:页面——视图、模板、搜索与分页
  6. Django6:表单与后台——让作者在网页上写文章
  7. Django7:登录与权限——谁能改哪篇文章
  8. Django8:上线与维护——测试、部署、备份与发布

下载可运行的示例项目(ZIP)

约 20 分钟 · 需要第 3 篇的模型

本篇目标: 让游客在浏览器里浏览、搜索和翻页阅读已发布的文章。

完成标志: 首页能搜索、能翻页;草稿无论从列表、搜索还是直接输入地址都看不到。

1. 视图:收到请求,返回响应

视图是一个函数:接收 request(以及路由提取的参数),返回一个 HTTP 响应——可以是 HTML 页面、JSON、跳转或错误。

最简单的例子是健康检查,返回一段 JSON:

@require_GET
def health(request):
    return JsonResponse({"status": "ok"})

@require_GET 限制只接受 GET 请求。这个接口只能说明“请求能进入 Django”,不检查数据库是否可用。

blog/views.py 顶部需要这些导入:

from django.core.paginator import Paginator
from django.db.models import Q
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, render
from django.views.decorators.http import require_GET
from .models import Post

2. 列表页:筛选 → 搜索 → 预加载 → 分页

@require_GET
def post_list(request):
    q = request.GET.get("q", "").strip()[:100]
    posts = Post.objects.filter(status=Post.Status.PUBLISHED)
    if q:
        posts = posts.filter(Q(title__icontains=q) | Q(body__icontains=q))
    posts = posts.select_related("author", "category").prefetch_related("tags")
    page_obj = Paginator(posts, 10).get_page(request.GET.get("page"))
    return render(request, "blog/post_list.html", {"page_obj": page_obj, "q": q})

顺序很关键:

  1. 先限定已发布——这是安全边界;
  2. 再叠加搜索条件——即使关键词命中草稿正文,草稿也已经被第 1 步排除了;
  3. 再预加载关联(第 4 篇的 N+1 优化);
  4. 最后分页。

几个细节:

  • request.GET 是 URL 里 ?q=Django&page=2 这部分参数,和 HTTP 的 GET 方法不是一回事。
  • 关键词去掉首尾空格并截断到 100 个字符,避免超长输入。
  • Paginator(posts, 10) 每页 10 条。get_page() 很宽容:页码不是数字就回到第 1 页,超出范围就到最后一页。

3. 详情页:查询条件就是访问边界

@require_GET
def post_detail(request, slug):
    post = get_object_or_404(
        Post.objects.select_related("author", "category").prefetch_related("tags"),
        slug=slug, status=Post.Status.PUBLISHED,
    )
    return render(request, "blog/post_detail.html", {"post": post})

查询条件里有 status=Post.Status.PUBLISHED。即使有人知道草稿的 slug,访问也只会得到 404。

只在列表里过滤是不够的

如果详情页写成 get_object_or_404(Post, slug=slug),知道地址的人就能直接打开草稿。“页面上没有链接”不是保护措施,每个查询都要带上公开条件。

作者想看自己的草稿,走第 6 篇的“我的文章 → 编辑”。

4. 连上路由

只完成公开页面时,blog/urls.py 可以先写这两条:

from django.urls import path
from . import views

app_name = "blog"
urlpatterns = [
    path("", views.post_list, name="list"),
    path("p/<slug:slug>/", views.post_detail, name="detail"),
]

完整版本见第 2 篇。总路由里要有 path("", include("blog.urls"))。

5. 公共布局:base.html

所有页面共享同一个头部和导航,写在 templates/base.html:

{% load static %}
<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{% block title %}Django 实战博客{% endblock %}</title>
  <link rel="stylesheet" href="{% static 'blog/style.css' %}">
</head>
<body>
  <header>
    <a href="{% url 'blog:list' %}">Django 实战博客</a>
    <nav>
      {% if user.is_authenticated %}
        <a href="{% url 'blog:mine' %}">我的文章</a>
        <a href="{% url 'blog:create' %}">写文章</a>
        <span>{{ user.username }}</span>
        <form method="post" action="{% url 'logout' %}" class="inline">
          {% csrf_token %}<button type="submit">退出</button>
        </form>
      {% else %}
        <a href="{% url 'login' %}">登录</a>
      {% endif %}
    </nav>
  </header>
  <main>
    {% for message in messages %}<p role="status">{{ message }}</p>{% endfor %}
    {% block content %}{% endblock %}
  </main>
</body>
</html>
  • {% block content %} 是留给子页面填内容的“坑”;{% block title %} 让子页面改标题。
  • {% url 'blog:list' %} 用路由名生成地址。

手工搭建的读者注意

<nav> 里引用了登录、退出和写文章的路由,它们要到第 6、7 篇才有。现在先把整个 <nav>...</nav> 删掉,否则会报 NoReverseMatch;之后再加回来。

6. 列表模板和分页

templates/blog/post_list.html:

{% extends 'base.html' %}
{% block content %}
<h1>最新文章</h1>
<form method="get">
  <label for="q">搜索标题或正文</label>
  <input id="q" name="q" value="{{ q }}" maxlength="100">
  <button type="submit">搜索</button>
</form>
{% for post in page_obj %}
  <article>
    <h2><a href="{{ post.get_absolute_url }}">{{ post.title }}</a></h2>
    <p>{{ post.author.username }} · {{ post.category|default:'未分类' }} · {{ post.created_at|date:'Y-m-d H:i' }}</p>
    <p>{{ post.body|truncatechars:120 }}</p>
    <p>{% for tag in post.tags.all %}<span>#{{ tag.name }} </span>{% endfor %}</p>
  </article>
{% empty %}
  <p>暂时没有符合条件的文章。</p>
{% endfor %}
{% include 'blog/pagination.html' %}
{% endblock %}
  • {% empty %} 在列表为空时显示提示。
  • 模板里调用方法不加括号:post.tags.all,不是 post.tags.all()。
  • |default、|date、|truncatechars 是过滤器,用来格式化显示。

分页片段 templates/blog/pagination.html:

<nav aria-label="分页">
  {% if page_obj.has_previous %}
    <a href="?q={{ q|default:''|urlencode }}&amp;page={{ page_obj.previous_page_number }}">上一页</a>
  {% endif %}
  <span>第 {{ page_obj.number }} / {{ page_obj.paginator.num_pages }} 页</span>
  {% if page_obj.has_next %}
    <a href="?q={{ q|default:''|urlencode }}&amp;page={{ page_obj.next_page_number }}">下一页</a>
  {% endif %}
</nav>

翻页链接带上了 q,所以搜索“Django”之后翻到第 2 页,仍然是搜索结果。urlencode 处理关键词里的空格和 & 等字符。

7. 详情模板与自动转义

templates/blog/post_detail.html:

{% extends 'base.html' %}
{% block title %}{{ post.title }} · Django 实战博客{% endblock %}
{% block content %}
<article>
  <h1>{{ post.title }}</h1>
  <p>{{ post.author.username }} · {{ post.created_at|date:'Y-m-d H:i' }}</p>
  <div>{{ post.body|linebreaks }}</div>
  <p>{% for tag in post.tags.all %}<span>#{{ tag.name }} </span>{% endfor %}</p>
</article>
{% endblock %}

linebreaks 把换行变成段落。Django 默认自动转义模板变量:正文里写 <script>,页面上显示的就是这几个字符,不会被当作脚本执行。

不要随手加 |safe

|safe 会关闭转义。用户写的内容一旦加了它,就可能被注入脚本。以后支持 Markdown 或富文本时,要先用白名单清理生成的 HTML。

8. 造数据,验收页面

只有一篇文章看不出分页效果。在 shell 里生成 12 篇:

from django.contrib.auth import get_user_model
from blog.models import Post

author = get_user_model().objects.get(username="admin")
for index in range(1, 13):
    Post.objects.get_or_create(
        slug=f"page-demo-{index}",
        defaults={
            "title": f"Django 分页示例文章 {index}",
            "body": f"这是用于观察分页行为的第 {index} 篇文章。",
            "author": author,
            "status": Post.Status.PUBLISHED,
        },
    )

启动服务器,逐项检查:

  • 首页显示文章,有“下一页”
  • 搜索 Django 有结果;搜索不存在的词显示空状态
  • 搜索后翻到第 2 页,仍是搜索结果
  • 访问 /p/no-such-post/ 返回 404
  • 在后台把一篇文章改成草稿:它从列表、搜索结果和详情地址同时消失

9. 常见错误

错误 常见原因
TemplateDoesNotExist 模板路径拼错,或 TEMPLATES 的 DIRS 没配置
NoReverseMatch 路由名、命名空间或参数不对,或引用了还没写的路由
列表是空的 还没有已发布的文章,或搜索条件没匹配上
草稿详情 404 正常,这是本例的公开规则
翻页后搜索条件丢了 分页链接没带上 q

小练习

1. 想在列表页加“按分类筛选”(/?category=3),视图怎么改?

在搜索条件之后加一段:

category = request.GET.get("category")
if category and category.isdigit():
    posts = posts.filter(category_id=int(category))

别忘了把 category 也传给模板,并在分页链接里带上它。

2. 正文里有用户写的 <b>加粗</b>,页面上显示的是什么?

显示原样的 <b>加粗</b> 这几个字符,不会加粗。这是自动转义在保护页面。