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

请求进入视图后,文章集合先按业务规则过滤,再交给 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 后,首页显示两篇文章,底部出现下一页;第二页显示剩下的一篇,并出现页码。

列表增长的入口已经收住。分类、标签、归档共用同一个页大小,后台设置也只维护一行数据。
源码
GitHub 地址:https://github.com/yidao620c/simpleblog