Django Admin 图表实战:0 依赖,Changelist 秒变数据看板
准备工作
PageVisit 模型每一条记录代表一次访问:
class PageVisit(models.Model):
# 页面类型选项:博客文章、项目、服务
PAGE_TYPE_CHOICES = [
("blog_post", "Blog Post"),
("project", "Project"),
("service", "Service"),
]
# 设备类型选项:桌面、手机、平板、爬虫、未知
DEVICE_TYPE_CHOICES = [
("desktop", "Desktop"),
("mobile", "Mobile"),
("tablet", "Tablet"),
("bot", "Bot"),
("unknown", "Unknown"),
]
page_type = models.CharField(max_length=20, choices=PAGE_TYPE_CHOICES)
object_id = models.PositiveIntegerField()
ip_address = models.GenericIPAddressField(null=True, blank=True)
timestamp = models.DateTimeField(default=timezone.now) # 访问时间
browser = models.CharField(max_length=100, blank=True, default="")
os = models.CharField(max_length=100, blank=True, default="")
device_type = models.CharField(max_length=10, choices=DEVICE_TYPE_CHOICES, blank=True, default="")
目标很明确:在标准列表页上方显示三个图表:
– 每日访问量的柱状图(支持 7 天 / 30 天 / 90 天切换)
– 按页面类型的环形图(饼图的一种)
– 按设备类型的环形图
第一步:在 changelist_view 注入图表数据
Django Admin 的 ModelAdmin 提供了一个 changelist_view 方法,我们可以重写它来向模板注入自定义数据。关键是在这里查询聚合数据,然后以 JSON 形式传给模板。
import json
from datetime import timedelta
from django.contrib import admin
from django.db.models import Count
from django.db.models.functions import TruncDate
from django.utils import timezone
from .models import PageVisit
@admin.register(PageVisit)
class PageVisitAdmin(admin.ModelAdmin):
# ... 其他配置(list_display、list_filter 等)
def changelist_view(self, request, extra_context=None):
# 从查询参数中读取天数,默认30天,只允许7、30、90
try:
period = int(request.GET.get("days", 30))
if period not in (7, 30, 90):
period = 30
except (ValueError, TypeError):
period = 30
# 大坑:Django Admin 的 ChangeList 会把不识别的查询参数当成无效过滤条件,然后重定向去掉它们
# 所以必须在调用 super() 之前把 'days' 参数从 request.GET 里去掉
if "days" in request.GET:
params = request.GET.copy()
params.pop("days")
request.GET = params
# 计算起始日期
since = timezone.now() - timedelta(days=period - 1)
# 按日期分组统计每日访问量
daily = (
PageVisit.objects.filter(timestamp__gte=since)
.annotate(date=TruncDate("timestamp"))
.values("date")
.annotate(count=Count("id"))
.order_by("date")
)
# 按页面类型统计总访问量
by_type = (
PageVisit.objects.values("page_type")
.annotate(count=Count("id"))
.order_by("page_type")
)
# 按设备类型统计总访问量
by_device = (
PageVisit.objects.values("device_type")
.annotate(count=Count("id"))
.order_by("device_type")
)
# 将查询结果序列化为 JSON 字符串,传给模板
extra_context = extra_context or {}
extra_context["chart_period"] = period
extra_context["chart_daily_labels"] = json.dumps(
[row["date"].strftime("%b %d") for row in daily] # 格式化日期,如 "Mar 15"
)
extra_context["chart_daily_data"] = json.dumps(
[row["count"] for row in daily]
)
extra_context["chart_type_labels"] = json.dumps(
[row["page_type"] for row in by_type]
)
extra_context["chart_type_data"] = json.dumps(
[row["count"] for row in by_type]
)
extra_context["chart_device_labels"] = json.dumps(
[row["device_type"] for row in by_device]
)
extra_context["chart_device_data"] = json.dumps(
[row["count"] for row in by_device]
)
return super().changelist_view(request, extra_context=extra_context)
第二步:覆写 Admin 的 Changelist 模板
Django 的模板加载规则是:先找 templates/admin/<app名称>/<模型名称>/change_list.html,找不到才用内置模板。我们需要创建这个文件,并继承默认模板。
文件路径示例:
你的项目/
templates/
admin/
analytics/ # app 名称,这里假设叫 analytics
pagevisit/ # 模型小写名称
change_list.html # 这个文件
模板内容:加载 Chart.js,在原有的表格上方渲染三个图表卡片。
{% extends "admin/change_list.html" %}
{% block content %}
<!-- 从 CDN 加载 Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
.pv-charts {
display: grid;
grid-template-columns: 2fr 1fr 1fr; /* 柱状图占两列,两个环形图各占一列 */
gap: 1.5rem;
margin-bottom: 2rem;
align-items: start;
}
.pv-chart-box {
background: #fff;
border: 1px solid #eaeaea;
border-radius: 4px;
padding: 1rem;
}
.pv-chart-box-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.pv-period-toggle a {
display: inline-block;
padding: 0.25rem 0.5rem;
border: 1px solid #ccc;
border-radius: 3px;
text-decoration: none;
color: #447e9b;
margin-left: 0.25rem;
}
.pv-period-toggle a.active {
background: #447e9b;
color: white;
border-color: #447e9b;
}
/* 关键:给 canvas 固定高度,否则 Chart.js 会把图拉长 */
.pv-chart-bar canvas {
height: 200px !important;
}
</style>
<div class="pv-charts">
<!-- 柱状图:每日访问量 -->
<div class="pv-chart-box pv-chart-bar">
<div class="pv-chart-box-header">
<h3>访问量</h3>
<div class="pv-period-toggle">
<a href="?days=7" class="{% if chart_period == 7 %}active{% endif %}">7天</a>
<a href="?days=30" class="{% if chart_period == 30 %}active{% endif %}">30天</a>
<a href="?days=90" class="{% if chart_period == 90 %}active{% endif %}">90天</a>
</div>
</div>
<canvas id="pvDaily"></canvas>
</div>
<!-- 环形图:按页面类型 -->
<div class="pv-chart-box">
<h3>页面类型</h3>
<canvas id="pvType"></canvas>
</div>
<!-- 环形图:按设备类型 -->
<div class="pv-chart-box">
<h3>设备类型</h3>
<canvas id="pvDevice"></canvas>
</div>
</div>
{# djlint:off #} <!-- 关闭 djLint 格式化,防止破坏 Django 模板语法 -->
<script>
(function() {
// 每日柱状图
new Chart(document.getElementById("pvDaily"), {
type: "bar",
data: {
labels: {{ chart_daily_labels|safe }}, <!-- 日期标签,用 |safe 防止被转义 -->
datasets: [{
data: {{ chart_daily_data|safe }}, <!-- 数值 -->
backgroundColor: "#417690",
maxBarThickness: 48, <!-- 限制最大条形宽度,数据少时图表不会变成粗柱子 -->
}],
},
options: {
maintainAspectRatio: false, <!-- 配合 CSS 固定高度,防止拉伸 -->
scales: {
y: {
beginAtZero: true,
ticks: { precision: 0 } <!-- 只显示整数 -->
}
},
},
});
// 页面类型环形图
new Chart(document.getElementById("pvType"), {
type: "doughnut",
data: {
labels: {{ chart_type_labels|safe }},
datasets: [{
data: {{ chart_type_data|safe }},
backgroundColor: ["#f56954", "#00a65a", "#f39c12"],
}]
},
options: {
maintainAspectRatio: false,
}
});
// 设备类型环形图
new Chart(document.getElementById("pvDevice"), {
type: "doughnut",
data: {
labels: {{ chart_device_labels|safe }},
datasets: [{
data: {{ chart_device_data|safe }},
backgroundColor: ["#3c8dbc", "#00c0ef", "#a0d0e0", "#dd4b39", "#777"],
}]
},
options: {
maintainAspectRatio: false,
}
});
})();
</script>
{# djlint:on #} <!-- 重新开启格式化 -->
{{ block.super }} <!-- 渲染默认的表格部分 -->
{% endblock content %}
几个小细节:
– {# djlint:off #} 和 {# djlint:on #}:如果你用了 djLint 格式化工具,它会把 <script> 里的 Django 模板标签(如 {{ ...|safe }})拆成多行,导致错误。用这对注释可以临时关闭格式化。
– maintainAspectRatio: false 配合 CSS 中 height: 200px !important:不然 Chart.js 会按自己的比例撑满容器,数据少时柱状图会变得巨大。
– 柱状图的 maxBarThickness: 48:当只有一两天数据时,限制柱子宽度,避免图表变成两块大砖头。
最终效果
现在 PageVisit 的列表页变成了这样:
– 一个每日访问量柱状图,右上角有 7天 / 30天 / 90天 切换按钮
– 一个按页面类型统计的环形图(博客文章 / 项目 / 服务)
– 一个按设备类型统计的环形图(桌面 / 手机 / 平板 / 爬虫 / 未知)
图表下方依然是标准的 Admin 表格,筛选、搜索、分页全部正常。
没有用到的东西
- 没有安装任何新的 Python 包(Chart.js 从 CDN 加载,只是一个
<script>标签) - 没有更换 Admin 主题
- 没有前端构建工具
只需要重写 changelist_view 和模板继承就够了。如果你已经有一个运行中的 Admin 和一个值得做图表的模型,跑通这套代码不会超过一小时。
