Django5:页面——视图、模板、搜索与分页
Django 实战:做一个个人博客 · 第 5 / 8 篇(展开目录)
全系列用 Django 5.2 LTS 做一个个人博客:游客看文章,作者登录后写文章,管理员维护分类和账号。Django1—Django8 是文章序号,不是 Django 版本号。
本篇目标: 让游客在浏览器里浏览、搜索和翻页阅读已发布的文章。
完成标志: 首页能搜索、能翻页;草稿无论从列表、搜索还是直接输入地址都看不到。
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 步排除了;
- 再预加载关联(第 4 篇的 N+1 优化);
- 最后分页。
几个细节:
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 }}&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 }}&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> 这几个字符,不会加粗。这是自动转义在保护页面。
- 感谢你赐予我前进的力量

