
简介基于Python的Django框架与ECharts图表库构建数据可视化报表的完整工程资源面向Web开发初学者或需要快速搭建图表展示模块的开发者。资源围绕Django的MVT设计模式通过视图函数向前端返回JSON数据再由ECharts渲染折线图、柱状图等常见图表覆盖从后端接口设计到前端交互展示的主要环节。压缩包共2000个文件约21.96MB以Python源码、Django模板、JavaScript与CSS静态资源以及多语言翻译文件为主并带有虚拟环境依赖与项目配置便于本地直接启动调试。目前已有1131人学习/下载。借助该工程可以掌握Django中模型、视图与模板的协作方式理解ECharts配置项及数据绑定逻辑同时参考其目录结构与接口写法快速迁移到自己的报表项目中适合课程设计、毕业设计或小型管理系统开发直接复用。1. 报表展示为什么选 python django echarts 这条链路拿 python django echarts 做报表展示是中小团队搭建内部数据看板时绕不开的成熟组合。Django 负责数据从数据库读出、按维度聚合、根据条件筛选最后吐出一份干净的 JSONECharts 负责图形折线、柱状、饼图、漏斗都在一个库内解决不需要团队里再养一个专职前端。它解决的问题很明确业务方每周都在要新报表通用 BI 工具要么改不动、要么报价高与其等现成平台不如用两条技术线把一个报表页快速搭出来。这套方案适合后端顺手、前端只涉及图表这一个局部场景的开发者也适合想把报表模块嵌入现有 Django 项目的团队。后面几章我不按官方文档顺序讲而是先把数据契约说清楚再走通渲染链路最后把高频踩坑一个个排掉。2. Django 后端出数把 ORM 聚合结果变成图表要的 JSON2.1 先定接口契约再动手写页面常见做法是直接在 view 里 render 一个模板然后前端从 HTML 里抠数据行数、金额用># views.py from datetime import date, timedelta from django.db.models import Sum, Count from django.db.models.functions import TruncDate from django.http import JsonResponse from .models import Order def daily_sales(request): end request.GET.get(end, date.today().isoformat()) start request.GET.get(start, (date.today() - timedelta(days30)).isoformat()) rows (Order.objects .filter(pay_statuspaid) .filter(pay_time__date__range(start, end)) .annotate(dayTruncDate(pay_time)) .values(day) .annotate(order_cntCount(id), total_amtSum(amount)) .order_by(day)) data { dates: [r[day].strftime(%Y-%m-%d) for r in rows], order_counts: [r[order_cnt] for r in rows], amounts: [float(r[total_amt]) for r in rows], } return JsonResponse(data, json_dumps_params{ensure_ascii: False})这段代码的关键在顺序annotate(dayTruncDate(pay_time)) 生成 day 字段values(day) 告诉 ORM 按 day 分组再 annotate 聚合订单数和金额。values 和 annotate 的顺序不能反先 values 再 annotate 才是「按某字段分组后聚合」先 annotate 再 values 有时看起来也在分组实际生成的 SQL 里 GROUP BY 是空的结果是整表合计这个坑后面我还会单独讲。TruncDate 是数据库内置日期函数的封装SQLite、MySQL、PostgreSQL 分别生成对应的 date(pay_time)跨库行为一致。start/end 从 query string 读取默认最近 30 天报表页的日期筛选框直接把值拼进 URL 就能生效。JsonResponse 默认会把中文转成 \uXXXX这里传 json_dumps_params{ensure_ascii: False} 是为了在浏览器里直接调试时肉眼能看懂。金额字段如果是 DecimalJsonResponse 序列化会直接抛异常所以 total_amt 要转成 float这是初学者最容易忽略的细节。2.3 注册路由并用 curl 验证数据链路views 写完先接 URL再接模板。报表接口不接路由访问就是 404view 函数名写错Django 会在请求时直接抛异常。所以先把这一环单独验证掉# urls.py from django.urls import path from . import views urlpatterns [ path(report/sales/, views.daily_sales, namereport-sales), ]启动开发服务器python manage.py runserver 0.0.0.0:8000然后用 curl 直接打接口不开浏览器也能确认返回结构curl http://127.0.0.1:8000/report/sales/?start2026-01-01end2026-01-31预期得到形如 {dates: [2026-01-01, ...], order_counts: [..], amounts: [..]} 的 JSON。如果这里返回空数组先确认表里有没有数据、filter 条件有没有把所有记录过滤干净如果返回 500看服务端终端日志里的 traceback通常是字段名写错或类型序列化问题。这一步的目的是把数据链路和图形渲染切开后面任何一步出问题都可以回到这个 curl 命令复现而不是在前端 console 里瞎猜。提示开发阶段可以开 DEBUGTrueDjango 会把异常信息直接打在页面上一旦接上 ECharts 后页面白屏第一件事回到 curl 验证接口而不是改前端配置。2.4 一张表同时出多个指标报表里经常要同时看订单数、销售额、客单价。除了 2.2 里的并列聚合客单价这种派生指标适合在后端直接算好for r in rows: r[unit_price] round(float(r[total_amt] / r[order_cnt]), 2) if r[order_cnt] else 0这里有一个业务上的边界如果某天订单数为 0division 会崩所以要加判断。派生字段在后端算前端拿到什么画什么避免每个报表页重复实现同一套除法逻辑。另一个常见需求是批发/零售等渠道维度可以在 values 里并列传多个字段例如 values(day, channel)ORM 会生成多列分组每一行代表某天某渠道的聚合结果。这种多维数据前端更适合用数据透视或者 ECharts 的多 series 展示接口契约在初期就要考虑预留维度字段不然上线后加渠道筛选要改一整套接口。3. 前端渲染链路引入 ECharts、初始化容器、对齐数据结构3.1 ECharts 的引入方式怎么选Django 项目引入 ECharts 有三种常见方式npm 管理、公共 CDN、本地静态文件。如果项目已经上了 webpack 或 Vitenpm 安装 echarts 再按需引入是最干净的如果只是 Django 模板直接渲染页面我一般建议把 echarts.min.js 下载到 static 目录不依赖外部网络也不受公共 CDN 在部分网络环境下加载慢的影响。下载时注意选 5.x 的完整包dist 目录下的 echarts.min.js 包含所有图表类型开发期不用纠结按需引入等页面多了再考虑换按需构建。mkdir -p static/js # 下载 echarts.min.js 放到 static/js/ 目录版本号不要再用 4.x引入顺序要保证 echarts.min.js 在业务脚本之前。Django 模板里用 static 标签引用{% load static %} script src{% static js/echarts.min.js %}/script script src{% static js/report.js %}/script业务逻辑单独放一个 report.js不要和模板混在一起。这样模板只负责结构脚本只负责渲染以后图表配置调整不用动 HTML。3.2 容器、初始化和 setOption 的最小模板ECharts 需要一个有宽高的 DOM 元素作为容器。报表页最常见的容器写法是 div 加上内联高度因为很多父级容器没有显式高度图表会把高度算成 0div idsalesChart stylewidth:100%;height:420px;/divreport.js 里用 fetch 拿接口数据再初始化图表// report.js fetch(/report/sales/) .then(res res.json()) .then(data { const chart echarts.init(document.getElementById(salesChart)); chart.setOption({ tooltip: { trigger: axis }, grid: { left: 40, right: 20, top: 30, bottom: 30 }, xAxis: { type: category, boundaryGap: false, data: data.dates }, yAxis: { type: value }, series: [{ name: 销售额, type: line, smooth: true, data: data.amounts }] }); });setOption 里几个参数值得说明boundaryGap: false 让折线从坐标轴最左侧开始柱状图则建议不写或保持默认留白grid 的 left/right/top/bottom 控制绘图区边距报表页里有 Y 轴单位或图例时预留空间能避免文字被裁掉smooth: true 只是视觉上把折线拉成曲线它不会改变数据点的真实值。series 的 name 会显示在 tooltip 和图例里一个图有多个指标时 name 必须唯一。fetch 默认不带凭证同源部署下不需要额外配置如果 Django 站点和页面域名不一致fetch 会遇到跨域问题。报表系统我建议保持同源部署否则 CORS、Cookie 和 CSRF 全都要额外处理投入产出比很低。3.3 init 时机和容器尺寸白屏的第一嫌疑echarts.init 最常见的报错是 Initialize failed: invalid dom字面意思就是传入的 DOM 元素不存在。脚本放在 head 里或者放在容器 div 前面执行时document.getElementById 拿不到元素自然 init 失败。把脚本挪到 body 末尾通常能解决但更稳妥的做法是包一层 DOMContentLoadedwindow.addEventListener(DOMContentLoaded, function () { const el document.getElementById(salesChart); if (el) { const chart echarts.init(el); // setOption 在这里调用 } });还有一种情况 DOM 存在但高度是 0比如父级是 flex 布局且没有给子项分配高度或者容器在 CSS 里被设为 height: auto。ECharts 初始化时会读取容器宽高做画布布局高度为 0 时画布不会报错但页面里什么都看不见。排查时在浏览器控制台执行 document.getElementById(salesChart).clientHeight如果输出 0问题不在 ECharts在 CSS。3.4 饼图数据结构和后端接口对齐报表页不全是折线图饼图是渠道占比、品类分布这类场景的主角。饼图的数据格式和折线图不一样它是 [{name: 渠道A, value: 123}, ...] 的对象数组。后端接口应该直接返回这个结构而不是返回两个平铺数组让前端去 zipdef channel_report(request): rows (Order.objects .filter(pay_statuspaid) .values(channel) .annotate(totalSum(amount)) .order_by(-total)) items [{name: r[channel], value: float(r[total])} for r in rows] return JsonResponse({items: items})前端使用时就非常直接fetch(/report/channel/) .then(res res.json()) .then(data { const chart echarts.init(document.getElementById(channelChart)); chart.setOption({ series: [{ type: pie, radius: [40%, 70%], data: data.items }] }); });radius 用数组表示内径和外径[‘40%’, ‘70%’] 是环形饼图视觉上比实心饼图更常见。这套契约原则很简单后端组织成 ECharts 需要的样子前端永远是「拿数据填配置」两步不要在前端写数据重组逻辑。4. 页面组装与刷新权限、筛选参数和多图表脚本组织4.1 报表接口的权限控制报表数据通常是内部经营数据接口不能裸奔。最简单可靠的做法是在视图函数上加 login_required让 Django 的 session 认证接管访问控制from django.contrib.auth.decorators import login_required login_required def daily_sales(request): ...如果希望只有特定角色能看用 user_passes_test 包一层from django.contrib.auth.decorators import user_passes_test def staff_only(user): return user.is_authenticated and user.is_staff user_passes_test(staff_only) def daily_sales(request): ...加上权限后有一个前端需要注意的连带问题未登录用户 fetch 接口时会拿到 302 重定向到登录页fetch 默认跟随重定向并把登录页 HTML 当 JSON 解析控制台会报类似 Unexpected token 的错误容易被误判成后端接口坏了。实际是前端没有处理登录态。解决方法是页面的业务脚本先判断接口返回状态或者在 fetch 里配置 redirect: error 让重定向直接抛错再统一跳转登录页。4.2 筛选参数从 request.GET 读取并做校验报表页几乎都带筛选条件开始时间、结束时间、渠道。后端 view 不能写死参数要从 query string 读取。同时要防一手用户传了非法日期导致 500from datetime import datetime login_required def daily_sales(request): start request.GET.get(start) end request.GET.get(end) channel request.GET.get(channel, ) try: start datetime.strptime(start, %Y-%m-%d).date() if start else None end datetime.strptime(end, %Y-%m-%d).date() if end else None except ValueError: return JsonResponse({error: date format should be YYYY-MM-DD}, status400) qs Order.objects.filter(pay_statuspaid) if start: qs qs.filter(pay_time__date__gtestart) if end: qs qs.filter(pay_time__date__lteend) if channel: qs qs.filter(channelchannel) ...参数校验的价值容易被低估内部系统用户传错日期格式很常见后端不校验Django 会在 ORM 层抛 ValidationError返回 500。返回 400 加上明确错误信息前端至少能给出提示而不是白屏。for 循环里拼条件而不是一开始就写死 filter是为了让渠道、时间等筛选条件可组合、可扩展。每次加一个筛选维度只需要在函数里加一个 if不用重写整个查询。4.3 多图表页面的脚本组织一个报表页通常有折线图、饼图、指标卡好几个模块不能每个图表复制一段 init 代码。我一般用容器上的>div classchart-box>const instances []; document.querySelectorAll(.chart-box).forEach(box { const chart echarts.init(box); instances.push(chart); fetch(/report/${box.dataset.chart}/) .then(res res.json()) .then(data { chart.setOption(buildOption(box.dataset.chart, data)); }); }); function buildOption(name, data) { if (name sales) { return { xAxis: { type: category, data: data.dates }, series: [{ type: line, data: data.amounts }] }; } if (name channel) { return { series: [{ type: pie, data: data.items }] }; } return {}; }instances 数组统一保存所有图表实例后续 window.resize 或 Tab 切换时批量调用 chart.resize() 非常方便。buildOption 按图表名返回不同配置新增图表时在模板里加一个容器、在 buildOption 里加一个分支不需要再写第二遍 init 逻辑。这里要强调图表实例创建后不要重复 init更新数据一律用 setOption。新版 ECharts 重复 init 同一个容器会报 Already initialized 警告并可能出现多个实例互相覆盖的诡异表现。5. 报表展示常见问题排查5 个高频翻车点与解决路径5.1 图表全空白Initialize failed 与高度为 0 的定位现象页面打开后图表区域一片空白控制台报 Initialize failed: invalid dom或者不报错但什么都没画出来。原因有两类一是 init 时 DOM 还没渲染完getElementById 返回 null二是 DOM 存在但高度被 CSS 计算成 0ECharts 画布建在 0 高度容器里自然不可见。解决先确认脚本执行时机把初始化包进 DOMContentLoaded再用 clientHeight 检查容器高度。我的排查顺序是先在控制台执行 document.getElementById(salesChart) 看是不是 null再看 clientHeight 是不是 0。两个检查做完问题基本定位到具体环节不用反复刷新页面瞎试。5.2 fetch 接口返回 403CSRF Token 没带上现象接口在浏览器地址栏直接访问正常但页面上 fetch 调用返回 403 Forbidden。原因Django 对 POST 请求开启 CSRF 校验fetch 默认不带 csrfmiddlewaretoken。报表系统的筛选条件如果通过 POST 提交这一步是必踩的坑。解决从 cookie 里取 csrftoken 并加到请求头这是 Django 官方推荐的 fetch 方案const csrfToken document.cookie .split(; ) .find(row row.startsWith(csrftoken)) ?.split()[1]; fetch(/report/sales/, { method: POST, headers: { X-CSRFToken: csrfToken, Content-Type: application/json }, body: JSON.stringify({ start, end }) });同时要确认 Django 视图已经使用了 csrf_protect 或者全局中间件开启。区分 GET 和 POST 的策略只读查询接口尽量用 GET 传参能绕过 CSRF会修改数据的接口再用 POST 并带上 token。5.3 聚合结果变成整表合计values 和 annotate 顺序写反现象图表只有一个点数值等于全表总和按天维度完全没有生效。原因Django ORM 里 values 和 annotate 的调用顺序直接决定 SQL 的 GROUP BY 内容。先 annotate 再 values聚合不按目标字段分组先 values 再 annotate才是分组后聚合。解决统一写法顺序filter - annotate(生成分组字段) - values(分组字段) - annotate(聚合函数) - order_by。如果不确定把生成的 SQL 打印出来看print(rows.query)看输出里有没有 GROUP BY day。没有 GROUP BY 就是分组逻辑没生效不要再看业务代码顺序改对就解决了。5.4 日期轴乱序字符串排序把 10 排到了 2 前面现象X 轴日期顺序是 01-10、01-02、01-03看起来是乱的。原因日期在 Python 里被 strftime 转成字符串后如果之后又被排序Python 默认按字典序排列01-10 01-02 因为 1 2。解决排序放在数据库层完成用 TruncDate 后的 day 字段直接 order_by。不要在 Python 里生成字符串列表后再 sort。如果必须在 Python 里排序用 date 对象排序后再格式化。这一条的经验是ORM 能完成的事不要在 Python 里再做一遍数据库的日期排序和字符串排序语义完全不同。5.5 setOption 后图表出现多条 series增量合并的副作用现象定时刷新后图表上多了一条之前的历史曲线刷新越多次线越多。原因ECharts 的 setOption 默认是增量合并模式。新配置里只写了一个 series旧的 series 会继续保留不是被替换。很多教程没讲清楚这一点实际项目里一刷新就翻车。解决给 series 设置固定的 id让新配置按 id 匹配替换chart.setOption({ series: [{ id: salesLine, type: line, data: data.amounts }] });如果整个图表配置都要重建用 setOption(option, true)第二个参数 notMerge 为 true 时完全替换。理解这个机制后定时刷新和局部更新就变得可控但要记住缺省情况是 merge不是 replace。6. 进阶技巧用 dataset 模式把数据更新和配置更新拆开前面几章的数据流程是「后端返回 dates 和 amounts前端手动填进 xAxis 和 series」。这套流程图少的时候没问题图一多每个图表都要维护 x 轴字段和 y 轴字段的映射改动频繁时容易漏。ECharts 的 dataset 模式可以解决这个问题后端返回一张类似数据表的二维结构前端在 series 里用 encode 声明哪一列画 X 轴、哪一列画 Y 轴。chart.setOption({ dataset: { source: data.rows // rows: [{ day: 2026-01-01, amount: 120 }, ...] }, xAxis: { type: category }, yAxis: { type: value }, series: [{ type: line, encode: { x: day, y: amount } }] });更新数据时只需要替换 dataset.source配置项完全不用动chart.setOption({ dataset: { source: newRows } });这样数据更新和图表配置彻底解耦。后端接口也可以简化成「返回行列结构」的通用格式一个接口喂给多个图表。搭配前面 4.3 的 buildOption 分发整个报表页的代码量能压缩一半。我在自己的项目里习惯把容器尺寸自适应也放进来窗口 resize 时批量调用 chart.resize()否则图表在浏览器缩放后会出现留白或裁切。还有一个容易漏的场景是 Tab 切换容器从 display:none 变成可见后宽高计算会失效必须在切换后手动 resize 一次。这是我吃过亏的地方后来统一封装了一个 renderChart 函数init、setOption、resize、销毁都走同一个入口报表页的图表部分就再没出过玄学问题。希望帮到你。本文还有配套的精品资源点击获取