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 和一个值得做图表的模型,跑通这套代码不会超过一小时。

类似文章