文章多了以后,列表页会变成一条长队。读者想找的内容通常很具体,可能看某个分类,可能记得写作月份,也可能只记得一个标签。我先把这三条找文章的路补上,同时给每一页加上侧边栏。

分类、标签、归档分别回答三个问题。分类说文章主要属于哪里,标签说文章还涉及什么,归档说文章出现在哪个时间。三者不互相替代。
先把反查入口放到模型上
分类和标签的 get_absolute_url 现在补上。
1
2
3
4
5
6
7
8
9
| from django.urls import reverse
class Category(models.Model):
def get_absolute_url(self):
return reverse("blog:category", args=[self.slug])
class Tag(models.Model):
def get_absolute_url(self):
return reverse("blog:tag", args=[self.slug])
|
这两个方法看起来只是两行反转。它们的价值在于,模板、后台跳转、未来的 RSS 和搜索结果都能调用同一个地址。分类页换路径时,只要模型方法更新,页面里的入口就不会散落。
路由集中在 blog/urls.py。
1
2
3
4
| path("category/<slug:slug>/", views.category_posts, name="category"),
path("tag/<slug:slug>/", views.tag_posts, name="tag"),
path("tags/", views.tag_cloud, name="tags"),
path("archive/<int:year>/<int:month>/", views.archive_posts, name="archive"),
|
slug 和 year、month 都用转换器约束。分类名不需要出现在路径里,固定一个短别名更稳定。归档路径用数字年月,不用字符串解析。
分类页只取一个分类的已发布文章
分类视图很简单。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| from django.shortcuts import get_object_or_404, render
from .models import Category, Post
def category_posts(request, slug):
category = get_object_or_404(Category, slug=slug)
posts = (
Post.published.filter(category=category)
.select_related("category")
.prefetch_related("tags")
)
return render(request, "blog/post_list.html", {
"posts": posts,
"page_title": f"分类:{category.name}",
**sidebar_context(),
})
|
这里的 get_object_or_404 负责处理不存在的 slug。后面的过滤从 Post.published 开始,仍然沿用前台唯一出口。就算有人在后台把分类建好了,文章还停在草稿状态,分类页也不会把它带出来。
标签视图逻辑相同。
1
2
3
4
5
6
7
8
9
10
11
12
| def tag_posts(request, slug):
tag = get_object_or_404(Tag, slug=slug)
posts = (
Post.published.filter(tags=tag)
.select_related("category")
.prefetch_related("tags")
)
return render(request, "blog/post_list.html", {
"posts": posts,
"page_title": f"标签:{tag.name}",
**sidebar_context(),
})
|
两个视图没有急着抽成同一个函数。它们目前只差一个过滤字段和标题前缀,重复两遍还能看清楚。等出现第三种类似筛选,再考虑共同参数和视图函数的组合。过早抽象常常把简单的过滤器写成配置对象。
归档页按年和月过滤。
1
2
3
4
5
6
7
8
9
10
| def archive_posts(request, year, month):
posts = Post.published.filter(
published_at__year=year,
published_at__month=month,
).select_related("category").prefetch_related("tags")
return render(request, "blog/post_list.html", {
"posts": posts,
"page_title": f"{year} 年 {month} 月归档",
**sidebar_context(),
})
|
项目开启了 USE_TZ,Django 会按当前时区解释年月查询。文章在 2026 年 9 月 1 日凌晨发布时,归档应该算 9 月,而不能因为数据库里保存 UTC 就落到 8 月。
侧边栏统一提供导航
侧边栏的数据来自一个函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| from django.db.models import Count
def sidebar_context():
archives = (
Post.published
.filter(published_at__isnull=False)
.values("published_at__year", "published_at__month")
.annotate(total=Count("id"))
.order_by("-published_at__year", "-published_at__month")
)
return {
"latest_posts": Post.published.all()[:5],
"popular_posts": Post.published.order_by("-views")[:5],
"sidebar_categories": Category.objects.annotate(total=Count("posts")).filter(total__gt=0),
"archives": archives,
}
|
函数名里带 context,说明它返回的是数据,不负责渲染。每个页面视图把结果展开进上下文,模板不需要知道聚合从哪来。
分类数量用 annotate(total=Count("posts")),再用 filter(total__gt=0) 过滤。空分类不出现在侧边栏,因为一个没有文章的入口只会带来 404。
归档分组先用 values 取年月,再聚合数量。生成的 SQL 会按年月分组,order_by 保证最近的月份排在前面。以后文章跨很多年,可以在这一层继续做年度折叠。
最新文章和热门文章都限制在 5 条。侧边栏是辅助导航,不能比正文还长。热门排序先沿用浏览量,后面如果要做更复杂的推荐,再单独讨论统计口径。
模板放进 includes/sidebar.html。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| <aside class="sidebar">
<div class="panel">
<h2>分类</h2>
<ul>
{% for category in sidebar_categories %}
<li><a href="{{ category.get_absolute_url }}">{{ category.name }}({{ category.total }})</a></li>
{% endfor %}
</ul>
</div>
<div class="panel">
<h2>归档</h2>
<ul>
{% for item in archives %}
<li>
<a href="{% url 'blog:archive' item.published_at__year item.published_at__month %}">
{{ item.published_at__year }} 年 {{ item.published_at__month }} 月({{ item.total }})
</a>
</li>
{% endfor %}
</ul>
</div>
</aside>
|
列表页和详情页都包含它。
1
| {% include "includes/sidebar.html" %}
|
页面外层改成两栏网格。
1
2
3
4
5
6
7
8
9
10
| .page-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
gap: 2rem;
align-items: start;
}
@media (max-width: 900px) {
.page-grid { grid-template-columns: 1fr; }
}
|
minmax(0, 1fr) 是关键。正文里有长代码时,普通 1fr 可能被内容撑开,整个页面横向滚动。加上 minmax(0, 1fr) 后,正文栏遵守网格宽度,代码块自己横向滚动。
打开一个分类页,左侧是筛选结果,右侧是分类、最新、热门和归档。

标签云用一个简单比例
标签云不是把所有标签平铺出来。出现频率要有视觉差异,但不能大到把小标签挤没。
1
2
3
4
5
6
7
8
| from django.db.models import Count
def tag_cloud(request):
tags = Tag.objects.annotate(total=Count("posts")).filter(total__gt=0)
max_count = max((tag.total for tag in tags), default=1)
for tag in tags:
tag.font_size = 100 + int(60 * tag.total / max_count)
return render(request, "blog/tag_cloud.html", {"tags": tags, **sidebar_context()})
|
max_count 是当前最多文章的标签数量。普通标签的字号是基准 100%,数量最多的标签放大到 160%。中间的标签按比例落在这个区间。
这个算法不复杂,但它有三个优点。第一,字号永远不会小于正文字号,标签仍然可点。第二,一张只有 1 篇和 2 篇文章的博客,也能看出差异。第三,新增热门标签时,其他标签会相对缩小,不会固定在旧权重上。
模板里用行内样式输出字号。
1
2
3
4
5
6
7
| <p class="cloud">
{% for tag in tags %}
<a style="font-size: {{ tag.font_size }}%" href="{{ tag.get_absolute_url }}">
{{ tag.name }}({{ tag.total }})
</a>
{% endfor %}
</p>
|
有人会担心行内样式。这里只是一个从后端算出的动态值,放进 CSS 类没有意义。公共布局仍然交给样式表,动态比例留在模板里,职责反而更清楚。
测试三条入口和标签云
在原有视图测试里加两条。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| def test_category_tag_and_archive_pages(self):
category_response = self.client.get(self.category.get_absolute_url())
tag_response = self.client.get(self.tag.get_absolute_url())
archive_response = self.client.get("/archive/2026/9/")
self.assertContains(category_response, "博客的第一页")
self.assertContains(tag_response, "博客的第一页")
self.assertContains(archive_response, "博客的第一页")
self.assertContains(category_response, "热门文章")
def test_tag_cloud_contains_tag(self):
response = self.client.get(reverse("blog:tags"))
self.assertContains(response, "Django")
self.assertContains(response, "font-size: 160%")
|
第一条测试一次覆盖三个页面。它们共同验证同一篇文章在已发布状态下能被不同入口找到。第二条测试检查标签云聚合和字号映射。
运行完整测试。
1
2
3
| uv run manage.py check
uv run manage.py makemigrations --check --dry-run
uv run manage.py test
|
本阶段共 15 条测试。
1
2
3
| Ran 15 tests in 2.880s
OK
|
博客现在不止有一条时间线。分类负责结构,标签负责主题,归档负责时间,侧边栏把这些入口放在每页旁边。读者可以从任何一个维度继续往下翻。
源码
GitHub 地址:https://github.com/yidao620c/simpleblog