本系列目录

本系列以 Django 5.2 LTS 和个人博客为例。Django1—Django8 是文章序号。

  1. Django1:部署——从本地启动到服务器运行
  2. Django2:配置——看懂项目结构与请求流程
  3. Django3:数据库——模型设计、关系与迁移
  4. Django4:ORM——增删改查、事务与查询优化
  5. Django5:页面——视图、模板、搜索与分页
  6. Django6:使用——表单、文章管理与 Admin
  7. Django7:权限——登录、会话与访问控制
  8. Django8:维护——测试、日志、备份与升级

下载全部教程与可运行示例(ZIP)。解压后进入 django-series/example/,按第一篇运行。

查看完整项目源码 · 查看示例验证范围

现在数据库里有文章了,但读者不应该通过 Python shell 阅读博客。本篇实现公开列表和详情页,并增加关键词搜索与分页,让同一份数据通过网页呈现出来。

使用完整示例可以直接查看效果。手工搭建时,先完成第三篇模型和迁移,将本篇模板保存到对应路径,再把已经实现的视图接入路由。

1. 视图的输入和输出

视图接收 request,必要时还会接收路由提取的参数。它最终返回一个 HTTP 响应。这个响应可以是 HTML、JSON、重定向或者错误状态。

本系列健康检查是一个最小的 JSON 示例,位于 blog/views.py

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

这里需要导入 JsonResponserequire_GET。这个端点只说明请求能进入 Django 并获得响应,不检查数据库是否可用。业务可读写检查应单独设计,不要把简单存活检查解释成全系统健康。

2. 列表视图:筛选、关联加载与分页

blog/views.py 中导入:

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

然后加入列表视图:

@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})

处理顺序很关键:先限制为已发布文章,再叠加搜索条件,再安排关联查询,最后分页。这样即使关键词命中了草稿正文,草稿仍然不会出现在结果中。

request.GET 读取 URL 查询参数,例如 /?q=Django&page=2。它与 HTTP GET 方法并不是同一个对象;前者是数据容器,后者是请求方法。关键词先去掉首尾空白,并限制长度为 100 个字符,避免把无意义的超长输入继续传给查询。

Paginator(posts, 10) 每页最多取 10 条。get_page() 能处理非数字和越界页码:非数字通常落到第一页,超出范围时落到最后一页。本例用稳定的默认排序避免相同时间下顺序不确定,但新增或删除内容时,偏移分页仍可能移动记录。Django 分页说明

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=published 的文章。即使知道草稿的 slug,访问也会得到 404。作者自己的草稿通过“我的文章”的编辑入口处理,本例没有单独的草稿预览地址。

如果只在列表里过滤草稿,却在详情页写 get_object_or_404(Post, slug=slug),知道地址的人就可能直接访问草稿。因此,公开边界应放在每个对应查询里,不能只依靠页面上有没有链接。Django 快捷函数

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"),
]

总路由需要有 path("", include("blog.urls"))。完整项目的 blog/urls.py 还包含健康检查和文章管理入口,第二篇已列出完整版本。后续逐步增加时保留现有条目即可。

5. 公共模板:页面共享一个布局

以下是完整项目的 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 %} 允许子页覆盖页面标题。导航栏使用具名路由,避免在多处写死路径。

这份完整布局还引用了登录、退出和文章管理路由。手工搭建尚未完成第六、七篇时,先删除整个 <nav>...</nav> 部分;相关路由补齐后再恢复。 否则模板反向解析未定义的路由会抛 NoReverseMatch。复制完整项目则无需做这个临时调整。

项目配置需包含 DIRS=[BASE_DIR / "templates"],静态样式来自 blog/static/blog/style.css。模板加载和变量查找机制见 Django 模板语言

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 不加 Python 式括号;Django 模板语言会按自身规则解析。前面视图预取了标签,模板循环就能复用结果。

再保存分页片段 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 后点击第二页仍处于同一搜索条件中。urlencode 处理关键词中的空格、& 等字符。HTML 属性里用 &amp; 分隔查询参数,浏览器会正确解释。

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;它会改变转义行为。

如果以后加入 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 能获得结果;搜索不存在的关键词出现空状态;第二页保留搜索条件;访问不存在的 slug 返回 404。再把一篇文章改为草稿,确认它从公开列表和详情入口同时消失。

9. 页面错误从哪里查

错误 常见原因
TemplateDoesNotExist 路径拼写错误,或模板目录没加入配置
NoReverseMatch 路由名、命名空间、参数不匹配,或引用了尚未实现的路由
列表空白 尚无已发布文章,或搜索条件没有匹配
草稿详情 404 符合本例公开规则,可以到“我的文章”编辑
样式 404 静态源路径错误;生产环境再检查收集与代理
翻页后搜索条件丢失 分页链接没有保留查询参数

下一篇 Django6:表单与后台 会让作者直接在页面上写文章,并说明管理员怎样维护分类和标签。


上一篇:Django4:ORM——增删改查、事务与查询优化 · 下一篇:Django6:使用——表单、文章管理与 Admin