使用Django6.1开发博客(5) - 分类标签与归档

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

https://static.xiongneng.me/taxonomy-class-diagram-20260926000000.png

分类、标签、归档分别回答三个问题。分类说文章主要属于哪里,标签说文章还涉及什么,归档说文章出现在哪个时间。三者不互相替代。

先把反查入口放到模型上

分类和标签的 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) 后,正文栏遵守网格宽度,代码块自己横向滚动。

打开一个分类页,左侧是筛选结果,右侧是分类、最新、热门和归档。

https://static.xiongneng.me/blog-category-sidebar-20260925220000.png

标签云用一个简单比例

标签云不是把所有标签平铺出来。出现频率要有视觉差异,但不能大到把小标签挤没。

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