使用Django6.1开发博客(7) - 分页与站点设置

列表、分类、标签和归档都会随着文章增多变长。我给它们加同一套分页,并把每页数量放进 Django Admin。编辑改一个数字,所有文章列表一起生效。

https://static.xiongneng.me/pagination-dataflow-20260926000000.png

请求进入视图后,文章集合先按业务规则过滤,再交给 Paginator。页大小来自 SiteSetting,页码来自 URL 的 page 参数。模板只接收 page_obj,不用关心当前视图是列表、分类还是归档。

全局设置只允许一行

站点设置通常只需要一条记录。用一个模型表示它,再把主键固定住。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
from django.db import models

class SiteSetting(models.Model):
    site_name = models.CharField("站点名称", max_length=80, default="Django 6.1 博客")
    page_size = models.PositiveSmallIntegerField("每页文章数", default=10)
    updated_at = models.DateTimeField("更新时间", auto_now=True)

    class Meta:
        verbose_name = "站点设置"
        verbose_name_plural = verbose_name

    def __str__(self):
        return self.site_name

    def save(self, *args, **kwargs):
        # 强制主键为 1,避免后台或脚本误建多行全局设置。
        self.pk = 1
        super().save(*args, **kwargs)

    @classmethod
    def load(cls):
        return cls.objects.get_or_create(pk=1)[0]

save 里的 self.pk = 1 让所有保存都落在同一行。load 用 get_or_create 初始化记录。第一次访问页面时会自动创建默认设置,不需要额外迁移数据。

每页数量用 PositiveSmallIntegerField。一个博客列表不太可能每页几万篇,小整数已经足够。如果以后允许管理员填任意数字,再在表单里加范围校验。

Admin 注册也很短。

1
2
3
4
5
6
7
8
9
from django.contrib import admin
from .models import SiteSetting

@admin.register(SiteSetting)
class SiteSettingAdmin(admin.ModelAdmin):
    list_display = ["site_name", "page_size", "updated_at"]

    def has_add_permission(self, request):
        return not SiteSetting.objects.exists()

已有设置后,has_add_permission 返回 false。后台不再显示新增入口。修改仍然走变更页。

分页收在一个函数里

所有文章列表都要分页,所以不要把 Paginator 复制四次。

1
2
3
4
5
6
from django.core.paginator import Paginator
from .models import SiteSetting

def paginate_posts(request, posts):
    page_size = SiteSetting.load().page_size
    return Paginator(posts, page_size).get_page(request.GET.get("page"))

列表页调用它。

1
2
3
4
5
6
def post_list(request):
    posts = Post.published.select_related("category").prefetch_related("tags")
    return render(request, "blog/post_list.html", {
        **sidebar_context(),
        "page_obj": paginate_posts(request, posts),
    })

分类页、标签页和归档页也替换成同样模式。它们先各自过滤业务范围,最后统一进入 paginate_posts。

1
2
3
4
5
6
page_obj = paginate_posts(request, posts)
return render(request, "blog/post_list.html", {
    **sidebar_context(),
    "page_obj": page_obj,
    "page_title": f"分类:{category.name}",
})

Paginator.get_page 是这里的关键。页码是 1 或 2 时返回对应页;页码不是数字、小于 1 或超过总页数时,它不抛异常,只返回第一页或最后一页。/list/?page=999 仍然是一个正常页面,不会变成 500。

这个选择适合博客。读者可能收藏了旧地址,文章删除后总页数变化,跳回最后一页比显示错误更友好。如果是 API,处理方式可能不同,显式返回 404 更容易让调用方发现问题。

模板只接收 page_obj

列表模板循环 page_obj。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{% for post in page_obj %}
  <article class="post-card">
    <h2><a href="{{ post.get_absolute_url }}">{{ post.title }}</a></h2>
    <p class="meta">
      {{ post.published_at|date:"Y-m-d H:i" }} · {{ post.category.name }} · {{ post.views }} 阅读
    </p>
    <p>{{ post.summary|default:post.body|truncatechars:120 }}</p>
  </article>
{% empty %}
  <p class="empty">还没有已发布文章。</p>
{% endfor %}

有第二页时再显示导航。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{% if page_obj.has_other_pages %}
  <nav class="pagination">
    {% if page_obj.has_previous %}
      <a href="?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="?page={{ page_obj.next_page_number }}">下一页</a>
    {% endif %}
  </nav>
{% endif %}

只有一页时不渲染任何分页控件。首页没有「上一页」,最后一页没有「下一页」。这比禁用按钮更安静。

样式让分页保持在内容底部。

1
2
3
4
5
6
7
.pagination {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 1rem;
  margin-top: 1.5rem;
}

以后列表页加入搜索或筛选参数,翻页链接也要带上这些参数。当前还没有查询条件,?page= 已经够用。

测试后台设置和边界页码

准备三篇文章,把页大小改成 2。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from ..models import Category, Post, SiteSetting

class PaginationTests(TestCase):
    def setUp(self):
        self.user = get_user_model().objects.create_user(username="editor")
        self.category = Category.objects.create(name="Python", slug="python")
        for number in range(1, 4):
            Post.objects.create(
                title=f"第 {number} 篇文章",
                slug=f"post-{number}",
                category=self.category,
                author=self.user,
                status=Post.Status.PUBLISHED,
                published_at=f"2026-09-0{number}T09:00:00+08:00",
            )

    def test_admin_can_change_page_size(self):
        setting = SiteSetting.load()
        setting.page_size = 2
        setting.save()

        first = self.client.get(reverse("blog:list"))
        second = self.client.get(reverse("blog:list"), {"page": 2})

        self.assertEqual(
            [post.title for post in first.context["page_obj"]],
            ["第 3 篇文章", "第 2 篇文章"],
        )
        self.assertContains(second, "第 2 / 2 页")

    def test_invalid_page_falls_back_to_valid_page(self):
        setting = SiteSetting.load()
        setting.page_size = 2
        setting.save()

        response = self.client.get(reverse("blog:list"), {"page": "999"})
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "第 2 / 2 页")

第一条测试检查了 response.context 里的对象列表,不只检查标题是否出现在 HTML。侧边栏也会显示文章标题,只看页面文本分不清主列表和最新文章。

第二条测试确认超过范围的页码不会抛异常。get_page 把它拉回最后一页。

生成迁移并运行测试。

1
2
3
uv run manage.py makemigrations blog
uv run manage.py migrate
uv run manage.py test

迁移输出如下。

1
2
3
Migrations for 'blog':
  blog\migrations\0004_sitesetting.py
    + Create model SiteSetting

最终测试结果如下。

1
2
3
Ran 21 tests in 2.942s

OK

把每页数量改成 2 后,首页显示两篇文章,底部出现下一页;第二页显示剩下的一篇,并出现页码。

https://static.xiongneng.me/blog-pagination-page2-20260926000000.png

列表增长的入口已经收住。分类、标签、归档共用同一个页大小,后台设置也只维护一行数据。

源码

GitHub 地址:https://github.com/yidao620c/simpleblog