模型稳住以后,博客该见人了。我先接通文章列表和详情页,顺手处理两个很容易拖到上线后的问题。草稿不能因为一个判断写漏就跑到前台;浏览量也不能在并发访问里被旧值覆盖。

一个读者请求先经过根路由,再进入 blog 应用的路由,最后到达视图。视图只取已发布文章,交给模板渲染。详情页还会多一步浏览量更新。
路由要从项目边界开始分清
config/urls.py 继续只做总入口。
1
2
3
4
5
6
7
| from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path("admin/", admin.site.urls),
path("", include("blog.urls")),
]
|
博客自己的路由放进 blog/urls.py。
1
2
3
4
5
6
7
8
9
| from django.urls import path
from . import views
app_name = "blog"
urlpatterns = [
path("", views.post_list, name="list"),
path("post/<int:pk>/", views.post_detail, name="detail"),
]
|
app_name 现在看不出作用,等后台模板、邮件模板或前端页面都要反转 URL 时,它能避免名字撞车。blog:list 和 blog:detail 比裸的 list 更容易检索。
详情页先用主键。有人喜欢一开始就做 slug 路由,可 slug 会因为标题调整而变动,主键始终稳定。等站内链接、缓存键和搜索跳转都清楚以后,再决定是否把 URL 全部迁到日期加别名。
路由名我也保留了少量冗余。path("", views.post_list, name="list") 里,路径是空字符串,名字却是 list。有人觉得空路径可以直接叫 index。我倾向 list,因为它描述的是资源集合的行为,不是技术上的默认首页。以后加归档、标签、分类页时,blog:archive、blog:tag、blog:category 会自然排在一起。
<int:pk> 也有一个隐含约定。它只匹配整数,不会把 /post/abc/ 送进视图,也不会让数据库先收到一个明显非法的值。Django 在路由层就把这类请求挡下来。URL 转换器看起来只是语法糖,实际减少了很多视图开头的类型判断。
视图只做请求层的事
列表视图非常短。
1
2
3
4
5
6
| from django.shortcuts import render
from .models import Post
def post_list(request):
posts = Post.published.select_related("category").prefetch_related("tags")
return render(request, "blog/post_list.html", {"posts": posts})
|
这里没有手写 filter(status="published"),因为上一篇已经在模型里定义了 Post.published。前台所有地方都走这个出口,草稿就只有一条规则可守。
select_related("category") 处理外键。列表页要显示分类名,如果不合并查询,每渲染一张卡片都会多查一次分类。prefetch_related("tags") 处理多对多。当前页面暂时还没有展示标签,先把查询取齐,下一篇展示标签时就不用改数据访问层。
详情页多了浏览量。
1
2
3
4
5
6
7
8
9
10
11
| from django.db.models import F
from django.shortcuts import get_object_or_404, render
def post_detail(request, pk):
post = get_object_or_404(
Post.published.select_related("category").prefetch_related("tags"),
pk=pk,
)
Post.objects.filter(pk=pk).update(views=F("views") + 1)
post.refresh_from_db(fields=["views"])
return render(request, "blog/post_detail.html", {"post": post})
|
F("views") + 1 会把加法交给数据库。两个人同时打开同一篇文章时,SQL 里都是「在当前值上加一」,不会出现两个人都拿着旧值 10 写回 11 的情况。随后 refresh_from_db 只取浏览量这一列,模板拿到的是最新值。
get_object_or_404 的第一个参数仍然是 Post.published。所以别人手动输入一篇草稿的详情地址时,看到的是 404。这个 404 就是产品语义,草稿对前台不存在。
我故意没有在视图里写分页、侧边栏、推荐文章这些东西。列表页刚出现时,最需要确认的是「取哪些数据」和「不取哪些数据」。分页会改变 URL 的含义,侧边栏会引入跨模型查询,推荐文章又牵涉排序规则。把它们塞进第一个视图,短期看完成得快,长期看每个功能都会在同一个函数里抢位置。
函数视图已经够用。它没有隐藏参数,也没有继承链,请求进来后先做什么、后做什么一眼能看到。Django 的通用视图当然有价值,比如 ListView 自带分页,DetailView 自带对象查找。可这些便利只在规则匹配时才省事。现在博客的规则还在形成中,先让普通函数把路径铺平,后面出现明显重复时再收拢。
模板异常也提醒我们目录配置的重要性。第一次跑测试时,视图和 URL 都正常,浏览器却提示 TemplateDoesNotExist。原因是 settings.py 里的 DIRS 仍然是空列表,Django 只会在应用目录里找模板。把 BASE_DIR / "templates" 加进去以后,公共模板才被找到。这个配置只写一次,但每个新项目都值得确认。
模板只负责展示
Django 6.1 默认会查找应用里的 templates 目录。这个项目的页面数量会增加,我先把公共页面放在 source/templates,把文章页放在 source/templates/blog。settings.py 里补上目录。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
|
base.html 保留每个页面都会用的骨架。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| {% 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 6.1 博客{% endblock %}</title>
<link rel="stylesheet" href="{% static 'css/blog.css' %}">
</head>
<body>
<header class="site-header">
<nav>
<a class="brand" href="{% url 'blog:list' %}">Django 6.1 博客</a>
<p class="tagline">把一个博客慢慢做完</p>
</nav>
</header>
<main class="layout">
{% block content %}{% endblock %}
</main>
</body>
</html>
|
列表页循环输出文章卡片。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| {% extends "base.html" %}
{% block title %}文章列表{% endblock %}
{% block content %}
<section class="content">
<h1>文章列表</h1>
<div class="post-list">
{% for post in posts %}
<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 %}
</div>
</section>
{% endblock %}
|
summary 可以在后台手工写。它为空时退回正文,并用 truncatechars 截断。这里不追求智能摘要,先让列表页不会把长正文全部铺出来。
模型里补一个反载数据库。
1
2
3
4
5
| from django.urls import reverse
class Post(models.Model):
def get_absolute_url(self):
return reverse("blog:detail", args=[self.pk])
|
模板调用 post.get_absolute_url,不用知道 URL 长什么样。以后调整路由,只要 get_absolute_url 还返回正确地址,列表页就不用跟着改。
base.html 现在只承担最稳定的三件事。第一是 HTML 语言和响应式视口,第二是页面标题的替换点,第三是全局样式入口。导航、页脚和侧边栏以后会出现,但它们也必须回答一个问题,这个区域是不是每一页都需要。答案不确定时,先不要放进基类。
列表模板用 {% extends %} 接住骨架,只填充标题和内容。这种继承比 include 更适合页面结构。include 适合复用一张卡片、一个表单行、一个按钮组;extends 适合固定页面外框。两者混用没有问题,但如果一个模板既继承又频繁反向包含,多半说明布局切分需要重新想。
文章详情页保留一条面包屑。它不是装饰,读者从搜索或后台预览进入详情时,需要一个明确的返回入口。链接文本直接写「文章列表」,不写「返回」。用户要返回的是一个页面,不是浏览器历史里的某一步。
样式先保持克制。纸张色背景、白色卡片、细边框、一个青绿色强调色,足够把层级做清楚。移动端只需要保证 max-width 和正常的字体行高,不需要一开始就堆组件。
这套样式里有几个值会反复出现,所以抽成 CSS 变量。--paper 控制背景,--ink 控制正文,--line 控制边界,--accent 控制强调。颜色不需要多,但同一个语义必须同一个值。否则今天列表卡片是 #ffffff,明天详情页是 #fff,后天某个容器又写白色,后面想统一调整就要全文搜索。
布局宽度限制在 760px。中文长段落超过这个宽度后,眼睛换行会变累。外层 .layout 允许到 1080px,是为了给以后的侧边栏留位置;正文区继续收窄,让阅读优先。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| body {
margin: 0;
background: #fbfaf8;
color: #172033;
font: 16px/1.75 system-ui, -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
}
.post-card {
background: white;
border: 1px solid #e6e2db;
border-radius: 1rem;
padding: 1.25rem 1.5rem;
margin-bottom: 1rem;
box-shadow: 0 8px 24px rgba(23,32,51,.04);
}
.content {
max-width: 760px;
margin: auto;
}
|
启动服务后打开首页,已发布文章会按发布时间排列。

点进标题,详情页显示完整正文、分类和浏览量。

测试要盯住访问边界
页面功能最容易退化的地方是状态过滤。blog/tests/test_views.py 里先写三条测试。
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
42
43
44
45
| from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from ..models import Category, Post
class PostViewTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(username="editor")
self.category = Category.objects.create(name="Python", slug="python")
self.post = Post.objects.create(
title="博客的第一页",
slug="first-page",
summary="列表和详情已经接通。",
body="这是详情页正文。",
category=self.category,
author=self.user,
status=Post.Status.PUBLISHED,
published_at="2026-09-25T09:00:00+08:00",
)
def test_list_shows_only_published_posts(self):
Post.objects.create(
title="不能出现的草稿",
slug="hidden-draft",
category=self.category,
author=self.user,
)
response = self.client.get(reverse("blog:list"))
self.assertContains(response, "博客的第一页")
self.assertNotContains(response, "不能出现的草稿")
def test_detail_renders_and_counts_view(self):
response = self.client.get(self.post.get_absolute_url())
self.post.refresh_from_db()
self.assertContains(response, "这是详情页正文。")
self.assertEqual(self.post.views, 1)
def test_draft_detail_returns_404(self):
self.post.status = Post.Status.DRAFT
self.post.save()
response = self.client.get(self.post.get_absolute_url())
self.assertEqual(response.status_code, 404)
|
这三条测试分别守住列表可见性、详情渲染加计数、草稿不可访问。它们都不依赖固定主键,也不检查某段 CSS 类名。页面重构时,这些测试不需要跟着改。
测试客户端不启动浏览器,也不执行 JavaScript,所以它适合验证响应状态、模板上下文和页面里的关键文本。布局是否溢出、按钮好不好点、暗色模式是否刺眼,这些仍然要打开真实页面看。两种检查各自服务不同问题,不能用一方完全替代另一方。
浏览量测试里还有一个细节。客户端请求详情页以后,代码调用 refresh_from_db。如果不刷新,self.post.views 还是内存里的旧值,测试可能碰巧通过,也可能在不同数据库顺序下失败。测试不能靠运气。
写这些测试时,我没有先打开浏览器截一个结果,再根据页面内容写断言。先写测试会强迫我把行为说清楚。比如草稿在列表里不出现是一件事,草稿详情返回 404 是另一件事。两个行为都成立,才算草稿真的不可见。如果只断言列表页没有标题,详情页仍然可能漏。
断言里的文本也选得比较挑剔。「博客的第一页」是标题,「这是详情页正文。」是正文。它们能证明模板拿到了正确对象。反过来,如果我断言某个 HTML 标签或某个 class,模板稍微调整结构,测试就会变红。这种失败并不代表业务坏了,只会让人慢慢忽略测试。
提交前运行检查。
1
2
3
| uv run manage.py check
uv run manage.py makemigrations --check --dry-run
uv run manage.py test
|
本阶段共 12 条测试,全部通过。
1
2
3
| Ran 12 tests in 2.967s
OK
|
这些数字本身不重要,重要的是变化。第一篇只有 3 条项目级测试,第二篇加入模型和后台后变成 9 条,这一篇补上页面后变成 12 条。每加一个功能,测试数量不需要追求固定比例,但已经建立的行为必须有地方守住。
评论、分页和标签页都还没加。页面数量很少,可请求路径已经完整。
源码
GitHub 地址:https://github.com/yidao620c/simpleblog