Python+Django+ECharts快速搭建内部数据报表看板
简介面向正在学习 Python Web 开发、希望把后端数据转化为可视化报表的开发者这份项目资料围绕 Django 与 ECharts 的配合展开覆盖模型与视图、接口返回 JSON、前端模板渲染图表以及静态文件管理等完整链路。整包共 2000 个文件、21.96MB以 py 源码和 pyc 缓存为主体辅以 html 模板、js 脚本、css 样式及 mo/po 本地化文件并保留 venv 环境与项目配置痕迹便于按目录结构检索和对照运行。已有 1132 人学习下载。读者可从中了解 Django 视图如何组织数据接口、ECharts 如何接收数据并配置折线图、柱状图、饼图等常见图表以及模板、静态资源与环境隔离的落地方式适合作为课程设计、报表模块原型或二次开发的参考底稿。1. 从一张明早要交的看板说起Python Django ECharts 到底解决什么问题月度经营会前一天业务方丢过来一句话「明早我要看到各渠道的销售走势和城市分布。」从零搭一套 BI 平台显然来不及。这时候 Python Django ECharts 这套组合就派上用场了Django 用 ORM 把库里的明细数据聚合成统计结果ECharts 在浏览器里把结果画成折线、柱状、饼图整条链路只有一条 Python 服务和几个静态文件不用额外的前端工程也不用买图表服务。它适合手里已经有业务库、需要快速交付内部报表的开发者也适合想把「取数—聚合—展示」跑通一遍再决定要不要上重型方案的人。下面按真实落地顺序拆骨架怎么搭、聚合怎么写、图表怎么配、坑在哪。2. 骨架与数据链路为什么模板直出比前后端分离更合适先把选型说清楚不然写到一半容易反复。做内部报表时常见的三种做法是Django 模板直出把数据塞进 context页面里渲染、Django 提供 JSON 接口 前端 fetch 渲染、完全前后端分离Vue/React 单独工程。第一种最快但图表数据一多模板里写{{ data|safe }}很容易出现转义和 XSS 问题第三种最规范但对一个「三天要上线」的报表来说构建链、跨域、部署全是额外成本。我一般选中间那条路页面用 Django 模板渲染骨架图表数据走独立的 JSON 接口。好处是职责清晰——模板只负责布局和静态资源引用接口只负责返回{code, data}结构ECharts 的option组装全在 JS 里完成。数据量翻倍时只需要改接口页面不用动后面要把这张图嵌到别的系统里接口直接复用。2.1 三层结构ORM 聚合 → 视图组装 → 前端渲染整条链路分三层每一层的输入输出都要约定死否则后期排查会很痛苦。层级职责输入输出数据层models ORM明细表、聚合查询过滤条件、时间范围QuerySetvalues 字典接口层views参数校验、结构统一、类型转换HTTP 查询参数{code:0,data:[...]}渲染层template ECharts组装 option、交互、自适应JSON 数组图表关键约定只有两条接口返回的每条记录必须是{name: ..., value: ...}这种扁平结构前端拿到就能直接喂给series.data所有Decimal在接口层统一转float绝不留到 JSON 序列化阶段再炸。这两条定下来后面加十张图都是复制粘贴。2.2 环境与最小可运行骨架先用一个模拟业务表起步字段贴近真实场景渠道、城市、金额、下单时间、是否支付。# 建虚拟环境并装依赖版本按自己项目的锁文件来不要盲目 latest python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install django4.2,5.0 # 长期支持版报表类项目够用 django-admin startproject report_site cd report_site python manage.py startapp reports模型只保留报表真正要用的字段索引加在时间字段上——后面所有按天、按月聚合都靠它。# reports/models.py from django.db import models class Order(models.Model): 模拟业务表一条订单明细 channel models.CharField(max_length32, db_indexTrue) # 渠道来源 city models.CharField(max_length32, db_indexTrue) # 城市 amount models.DecimalField(max_digits12, decimal_places2) created_at models.DateTimeField(db_indexTrue) # 聚合和筛选的主力字段 is_paid models.BooleanField(defaultFalse) class Meta: db_table t_order indexes [models.Index(fields[is_paid, created_at])] # 组合索引覆盖常见过滤db_indexTrue单独加在created_at上是为了范围过滤Meta.indexes里的组合索引是给「已支付 时间段」这种高频查询用的。报表项目的慢查询八成出在这一步字段没索引几十万行数据按月聚合就能卡到超时。注册 app、配置静态目录然后把模板骨架搭起来# report_site/settings.py 片段 INSTALLED_APPS [ django.contrib.contenttypes, django.contrib.staticfiles, reports, ] STATIC_URL /static/ STATICFILES_DIRS [BASE_DIR / static] # 放 dashboard.js 和 echarts.min.js!-- reports/templates/reports/dashboard.html -- {% load static %} !DOCTYPE html html langzh-CN head meta charsetutf-8 title渠道销售看板/title !-- 本地引入别依赖外部 CDN内网环境加载不出来就是一片空白 -- script src{% static js/echarts.min.js %}/script /head body div idchannel-chart stylewidth:100%;height:420px;/div script src{% static js/dashboard.js %}/script /body /html容器上的height:420px不是随便写的ECharts 初始化时会读容器尺寸高度为 0 或者父级display:none时图表就是一片白控制台还不报错。这个坑后面还会单独拎出来说。2.3 第一个柱状图从 QuerySet 到 option 的完整闭环先写接口。注意values()和annotate()的先后顺序这决定了分组字段是谁——写在values里的字段是分组维度annotate里的是聚合结果。# reports/views.py from django.db.models import Sum, Count from django.http import JsonResponse from django.shortcuts import render from .models import Order def dashboard(request): return render(request, reports/dashboard.html) def api_channel_amount(request): 按渠道汇总已支付金额返回 ECharts 可直接消费的结构 rows (Order.objects .filter(is_paidTrue) # 只统计已支付过滤放在聚合之前 .values(channel) # 分组维度 .annotate(totalSum(amount), cntCount(id)) .order_by(-total)) # 排序在数据库侧完成前端不再排 data [ {name: r[channel], value: float(r[total]), cnt: r[cnt]} for r in rows ] return JsonResponse({code: 0, data: data})float()这一步不能省。Sum(amount)出来的是DecimalDjango 默认的JsonResponse用DjangoJSONEncoder虽然能序列化Decimal但会转成字符串前端拿到1234.50再去做数值比较或者格式化就会出幺蛾子。统一在接口层转float前端只管画图。order_by(-total)放数据库侧是因为渠道可能有几十个排序结果直接影响前端取 TopN 的逻辑。前端侧把fetch和setOption拆开方便后面复用// static/js/dashboard.js const chart echarts.init(document.getElementById(channel-chart)); async function loadChannel() { const resp await fetch(/api/channel-amount/); const { data } await resp.json(); // 约定结构固定为 {code, data} chart.setOption({ tooltip: { trigger: axis, axisPointer: { type: shadow } }, grid: { left: 60, right: 24, top: 40, bottom: 60 }, xAxis: { type: category, data: data.map(d d.name), axisLabel: { rotate: data.length 8 ? 30 : 0 } // 渠道多时倾斜避免文字重叠 }, yAxis: { type: value, name: 金额元 }, series: [{ type: bar, data: data.map(d d.value), barMaxWidth: 36, itemStyle: { borderRadius: [4, 4, 0, 0] } }] }); } window.addEventListener(resize, () chart.resize()); // 侧边栏收起时也能跟上 loadChannel();grid的四个值是按「Y 轴标签宽度 X 轴文字倾斜 标题」估出来的默认值在中文标签下几乎必然被裁掉。barMaxWidth是防止只有两三个渠道时柱子被拉成巨型色块。resize监听挂在window上能应付窗口缩放但应付不了容器自身宽度变化这个放到避坑一节展开。跑起来验证先python manage.py migrate往表里塞几条测试数据python manage.py runserver打开页面。如果柱子高度正常、tooltip 能跟随这条链路就算通了。3. 报表聚合把计算压进数据库别用 Python 循环硬扛报表做多了会发现真正的难点不是画图是「怎么把明细算成统计值」。新手最典型的写法是把明细全filter出来然后在 Python 里for循环累加——几千行还行几十万行就是把数据库当存储、把应用服务器当计算引擎内存和响应时间双爆。正确姿势是把聚合下推到 ORM让数据库去算Python 只负责组装输出结构。3.1 values annotate分组统计的标准写法values(字段).annotate(别名聚合函数())是分组统计的固定套路。理解它的关键是values里列出的字段就是 SQL 的GROUP BY列表annotate里的是SELECT中的聚合列。# 按城市统计订单数和客单价 from django.db.models import Sum, Count, Avg rows (Order.objects .filter(is_paidTrue, created_at__gtestart, created_at__ltend) .values(city) .annotate(order_cntCount(id), gmvSum(amount), avg_amountAvg(amount)) .order_by(-gmv)[:20]) # 只取前 20 个城市避免图表挤成一团created_at__gte/__lt这种左闭右开的写法比__range更安全跨月统计时不会把边界那一天的零点重复计入。Count(id)比Count(*)语义清晰虽然生成的 SQL 差不多。切片[:20]会直接变成 SQL 的LIMIT 20不是在 Python 里截断这点很关键——限制在数据库侧做传输量才降得下来。需要多个维度交叉时values里加字段就行# 渠道 × 城市 的二维汇总用于堆叠柱状图 rows (Order.objects.filter(is_paidTrue) .values(channel, city) .annotate(gmvSum(amount)) .order_by(channel, -gmv))返回的是扁平的字典列表每行带channel、city、gmv三个键。前端要做堆叠图得自己按channel分组拼成series数组这一步放在接口层做比放前端做更省事也更容易加缓存。3.2 条件聚合一次查询算出多个口径报表经常要在同一张图上展示「总金额」和「已支付金额」两个口径。写成两次查询再合并不仅多一次往返还可能出现两次查询之间数据变动导致的不一致。用Case/When做条件聚合一条 SQL 就能出结果。from django.db.models import Case, When, Value, DecimalField, Sum, Count rows (Order.objects .values(channel) .annotate( total_gmvSum(amount), # 全部订单金额 paid_gmvSum(Case( # 仅已支付的金额 When(is_paidTrue, thenamount), defaultValue(0), output_fieldDecimalField(max_digits12, decimal_places2), )), paid_cntCount(Case(When(is_paidTrue, thenid))), # 条件计数 ))output_field在Sum(Case(...))里是必须的Django 靠它推断返回类型缺了在部分数据库后端会直接抛FieldError。Count(Case(When(...)))统计的是非NULL值不满足条件的行Case返回NULL正好被Count跳过——这是条件计数不用写filter的原因。这个写法在支付转化率、退款率这类报表里能省掉一半查询。3.3 时间粒度按天、按周、按月聚合时间序列图最常翻车的地方是「日期不连续」。直接按天分组没有订单的那天在结果里根本不存在折线图会把这些天跳过画出来是一条斜率失真的斜线看上去业务天天在涨实际中间空了三天。from django.db.models.functions import TruncDate, TruncMonth, TruncWeek def api_daily_trend(request): rows (Order.objects.filter(is_paidTrue) .annotate(dayTruncDate(created_at)) # 数据库侧截断到天 .values(day) .annotate(gmvSum(amount)) .order_by(day)) # 结果里只有有数据的日期缺口要自己补 return JsonResponse({code: 0, data: [ {date: r[day].strftime(%Y-%m-%d), value: float(r[gmv])} for r in rows ]})TruncDate走的是数据库的日期截断函数比在 Python 里strftime快一个量级而且能命中created_at的索引。TruncMonth、TruncWeek同理切换粒度只改一行。补缺口放在接口层做先用datetime生成完整的日期序列再用查询结果做字典映射缺失的补 0。这样前端拿到的xAxis.data和series.data长度永远一致不会出现错位。3.4 接口层统一结构让十张图共用一套前端代码图表一多最怕每个接口返回结构都不一样。定一个统一外壳{code: 0, msg: ok, data: {...}}data内部再按图表类型分。折线图给{x: [...], series: [{name: ..., data: [...]}]}饼图给[{name: ..., value: ...}]。前端写一个renderChart(el, option)的公共函数各页面只负责拼option渲染逻辑不重复。参数校验也要在接口层收口。时间范围、粒度、TopN 这些查询参数用request.GET.get()取出来之后必须做类型转换和边界限制——top传个100000进来等于让数据库全表排序。常见做法是写个小的parse_int工具函数给默认值、给上限非法输入直接返回错误码而不是让它进 SQL。4. ECharts 侧的四类图表配置项怎么写才不出洋相图表配置看着是前端活但报表项目的配置项基本是后端开发顺手写的所以这一章按「什么业务场景配什么图、关键项写什么值」来组织。四类图覆盖了内部报表八成的需求趋势看折线、对比看柱状、构成看饼图、两个量纲放一起看双轴。图表类型典型场景必须显式配置的项折线图日/月趋势、同比环比xAxis.boundaryGap、dataZoom、connectNulls堆叠柱状图渠道构成、分类对比series.stack、series.barMaxWidth、图例顺序饼图/环形图占比、TopN 其他radius、label.formatter、minAngle双 Y 轴金额与单量、流量与转化yAxis数组、series.yAxisIndex、轴颜色对应4.1 折线图时间轴、空值与缩放时间序列的xAxis用type: category而不是time原因是后端已经补全了连续日期用类目轴能保证「每个点等间距」不会因为周末没有数据而被压缩。配boundaryGap: false让折线从 Y 轴起始视觉上更符合趋势图的直觉。// 趋势图日期连续 可缩放 空值断点 const trendChart echarts.init(document.getElementById(trend-chart)); trendChart.setOption({ tooltip: { trigger: axis }, xAxis: { type: category, boundaryGap: false, data: xLabels, // 后端补全的连续日期长度与 series 一致 axisLabel: { hideOverlap: true } // 日期多时自动隐藏重叠标签 }, yAxis: { type: value, scale: true }, // scale:true 让 Y 轴不从 0 起波动更明显 dataZoom: [ { type: inside, start: 70, end: 100 }, // 滚轮缩放默认看最近 30% { type: slider, height: 20, bottom: 8 } // 底部滑块方便拖拽定位 ], series: [{ name: GMV, type: line, smooth: false, // 报表别开平滑平滑曲线会让数据看起来比实际好看 connectNulls: false, // 真空值就断开不要连成假趋势 showSymbol: xLabels.length 40, // 点太密时隐藏圆点减少渲染量 data: values }] });smooth: false是血泪经验开了平滑之后两个数据点之间会画出并不存在的峰值业务方拿着截图来对数据解释半天。scale: true同理金额从 10 万到 12 万波动Y 轴从 0 起的话整条线几乎是平的看不出任何信息但要在图上标注「Y 轴未从 0 开始」避免误读。dataZoom的start: 70表示默认显示后 30% 区间长周期报表打开就能看到最近的数据。4.2 堆叠柱状图series 顺序和 stack 必须对齐堆叠图的核心是同一组stack名字的series会叠在一起。坑在于「图例顺序」和「堆叠顺序」是两回事图例按series数组顺序排堆叠从下往上也是这个顺序但业务方往往希望某个大头在最底下。控制方法就是控制series数组的构造顺序别指望 ECharts 帮你排。// 渠道 × 城市堆叠接口返回扁平数组前端按维度重组 const channels [...new Set(raw.map(r r.channel))]; const cities [...new Set(raw.map(r r.city))]; const series channels.map(ch ({ name: ch, type: bar, stack: total, // 同一 stack 名才会堆叠 barMaxWidth: 40, emphasis: { focus: series }, // hover 时高亮整条系列 data: cities.map(c { const hit raw.find(r r.channel ch r.city c); return hit ? hit.gmv : 0; // 缺组合补 0保证长度一致 }) })); stackChart.setOption({ tooltip: { trigger: axis, axisPointer: { type: shadow } }, legend: { data: channels }, // 显式给图例顺序即 series 顺序 xAxis: { type: category, data: cities }, yAxis: { type: value }, series });data数组长度必须和xAxis.data完全一致缺的组合要补 0 而不是跳过——跳过会导致后面的数据整体左移图看起来对但数值全错这类问题肉眼极难发现。emphasis.focus: series让鼠标悬停时其它系列变淡多系列堆叠时可读性提升明显。4.3 饼图与环形图TopN 加「其他」渠道可能有几十个全塞进饼图就是一盘碎渣。标准做法是取 TopN剩下的合并成「其他」。合并动作在接口层做前端不应该知道业务规则。// 环形图中心留白放总额小占比合并展示 pieChart.setOption({ tooltip: { trigger: item, formatter: {b}: {c} ({d}%) }, legend: { orient: vertical, right: 12, top: middle }, series: [{ type: pie, radius: [45%, 68%], // 内半径/外半径形成环形 center: [40%, 50%], // 给右侧图例留空间 minAngle: 3, // 小于 3 度的扇区强制撑开避免看不见 avoidLabelOverlap: true, label: { formatter: {b}\n{d}% }, data: pieData // [{name, value}, ...]已含「其他」 }], graphic: [{ // 环心文字用 graphic 而不是 title type: text, left: 36%, top: 46%, style: { text: 总 GMV, fontSize: 13, fill: #888 } }] });minAngle解决的是「1% 的扇区细得点不中」的问题avoidLabelOverlap解决标签打架。环心文字用graphic而不是title因为title是相对整个画布定位的图例一挪位置就对不上了。4.4 双 Y 轴量纲不同的两个指标怎么放一张图「金额」和「订单数」放一张图是最常见的需求量纲差两个数量级共用一根 Y 轴的话订单数会被压成一条贴着 X 轴的直线。双轴的标准配置是yAxis给数组、series里用yAxisIndex指定挂哪根轴同时把轴线和系列颜色对应起来否则读者分不清哪根轴对应哪条线。// 金额左轴 订单数右轴 dualChart.setOption({ tooltip: { trigger: axis }, legend: { data: [GMV, 订单数] }, xAxis: { type: category, data: xLabels }, yAxis: [ { type: value, name: GMV元, axisLine: { show: true, lineStyle: { color: #5470c6 } } }, { type: value, name: 订单数, axisLine: { show: true, lineStyle: { color: #91cc75 } }, splitLine: { show: false } } // 右轴不画网格线否则两组网格交错视觉噪音大 ], series: [ { name: GMV, type: bar, yAxisIndex: 0, data: gmvValues, barMaxWidth: 36 }, { name: 订单数, type: line, yAxisIndex: 1, data: cntValues, smooth: false } ] });右轴的splitLine.show: false是必须的两组网格线叠在一起读图的人会下意识拿左轴刻度去读右边的线。系列颜色和轴线颜色保持一致是最省事的降低误解手段。5. 排查与避坑图表空白、数据错位的五个现场报表上线后出问题症状往往只有一句「打不开」或者「数不对」没有堆栈没有日志。下面五条是这类项目里复现率最高的每条按「现象 → 原因 → 解决」写遇到时可以直接对照。5.1 图表一片空白控制台没有任何报错现象页面能打开div也在 DOM 里但画布区域是白的network 面板里接口返回 200 且数据正常。原因echarts.init()执行时容器高度为 0。最常见的是容器写在display:none的 tab 页里或者父级用 flex 布局但没给固定高度height:100%在父级高度为 auto 时算出来就是 0。ECharts 按初始尺寸算画布尺寸为 0 时它什么都不画也不报错。解决给容器写死像素高度height:420px或者确保父链上每一级都有确定高度。如果图表在 tab 里改成「切换 tab 后再init」已经初始化过的调chart.resize()。// tab 切换后再渲染避免在隐藏容器里 init document.querySelectorAll([data-tab]).forEach(btn { btn.addEventListener(click, () { const el document.getElementById(btn.dataset.tab); if (!el.dataset.inited) { // 首次可见时才初始化 initChart(el); el.dataset.inited 1; } else { echarts.getInstanceByDom(el).resize(); } }); });5.2 接口返回正常前端报 “Converting circular structure” 或数据是字符串现象图能出来但数值全是NaN或者浏览器控制台报 JSON 序列化相关错误。原因两个方向。后端把Decimal直接塞进JsonResponse序列化成字符串1234.50前端拿去做数值运算就成了NaN或者视图里直接return JsonResponse(queryset)QuerySet 不是可序列化对象个别场景会绕到模型实例的循环引用上报错。解决接口层强制走一遍「values()→ 字典列表 → 显式类型转换」Decimal一律float()日期一律strftime成字符串。JsonResponse只接收dict或list永远不要直接扔 QuerySet 或模型实例进去。转换这一步写成公共函数所有接口复用别指望每个视图都记得。5.3 中文标题显示成方块或乱码现象图表里的轴标签、图例、tooltip 中文变成一个个方框HTML 文本部分正常。原因ECharts 用 canvas 渲染文字走的是系统字体不走 CSS 的font-family继承。服务端环境尤其是精简版容器镜像没装中文字体或者页面charset不是 UTF-8都会出现这种情况。解决模板头部确认meta charsetutf-8ECharts 全局配置里显式指定textStyle.fontFamily部署环境装一套基础中文字体。// 全局字体配置放在所有图表初始化之前 echarts.registerTheme(report, { textStyle: { fontFamily: Microsoft YaHei, PingFang SC, Noto Sans CJK SC, sans-serif } }); const chart echarts.init(document.getElementById(channel-chart), report);5.4 折线图走势「过于漂亮」和明细对不上现象业务方拿明细表逐日核对发现图上某几天的值明显偏高或者曲线比实际平滑。原因xAxis.data只有有数据的日期缺口日期被跳过或者开了smooth: true两点之间插值出并不存在的峰值再或者connectNulls: true把空值连起来了。解决接口层补全日期序列缺失补 0保证xAxis.data与series.data等长smooth一律falseconnectNulls保持false。补日期的逻辑要单测跨月、跨年、闰月都跑一遍别在边界上翻车。5.5 侧边栏收起后图表被压扁窗口缩放也不恢复现象点击侧边栏折叠按钮内容区变宽了但图表还是原来的宽度右侧留一大片空白或者被裁掉。原因只监听了window.resize。侧边栏折叠不改变窗口尺寸只改变容器尺寸window的 resize 事件根本不会触发。ECharts 内部不会自动感知父容器变化。解决用ResizeObserver监听图表容器本身比监听 window 精准得多。// 监听容器尺寸变化兼容侧边栏折叠、栅格重排 const ro new ResizeObserver(() chart.resize()); ro.observe(document.getElementById(channel-chart)); // 页面销毁时记得断开避免 SPA 场景下内存泄漏 // window.addEventListener(beforeunload, () ro.disconnect());这一条属于「不上线不知道」的典型。本地开发全屏浏览器永远复现不出来一进带侧边栏的后台布局就翻车。6. 进阶报表组件化与出数前的对账习惯图表数量从三张涨到二十张之后重复代码会迅速失控。我的做法是把「接口调用 option 组装 实例管理」抽成一个createReport(el, {url, buildOption})的工厂函数各页面只写buildOption实例的resize、销毁、loading 状态统一在工厂里管。这样加一张新图只需要写一个buildOption和一个接口不用再复制那几十行样板。比组件化更重要的是对账。报表最怕的不是画不出来是画错了还没人发现。我固定走三条线交叉验证ORM 聚合结果、直接连库手写 SQL 的结果、页面导出的明细合计三者必须一致。# 对账脚本ORM 聚合 vs 原生 SQL差一分钱都要查 from django.db import connection from django.db.models import Sum from reports.models import Order def check_month(day): orm_total (Order.objects .filter(is_paidTrue, created_at__dateday) .aggregate(sSum(amount))[s]) or 0 with connection.cursor() as cur: # 绕开 ORM 手写一遍 cur.execute( SELECT COALESCE(SUM(amount),0) FROM t_order WHERE is_paid 1 AND DATE(created_at) %s, [day] ) raw_total cur.fetchone()[0] diff float(orm_total) - float(raw_total) print(day, orm_total, raw_total, OK if abs(diff) 0.01 else fDIFF{diff})aggregate返回的是Decimal和float混算会有精度噪音所以两边都转float再比容差给 0.01。这个脚本挂到每日定时任务里跑完把差异写日志比等业务方来问强得多。从那以后我每次交付报表上线前都会强制走一遍「明细总数对聚合总数」的核对哪怕只加了一个filter条件也不例外——那个条件漏了is_paid整张图的数字就全错了而且错得很合理没人看得出来。希望帮到你。本文还有配套的精品资源点击获取