简介:围绕Django框架开发的城市PM2.5空气质量数据可视化分析源码,面向高校数据可视化课程与期末大作业设计,以北京、上海、广州、成都、沈阳五城六年PM2.5数据为分析对象,完整覆盖数据导入、数据库设计、后端逻辑与前端展示,是一份个人评分98分以上的高分大作业项目,适合需要完整可运行参考方案的在校生与初学者。压缩包共64个文件,类型涵盖Python代码、CSV数据、HTML模板、XML配置与SQL脚本,包体约12.38MB。其中py文件对应Django路由、视图、模型及数据导入脚本,CSV按城市和气象因子组织,包括PM2.5与温度、湿度、气压、风向、露点等关系数据,并配有登录、注册、首页等前端页面和数据库文件。资源内含不同季度逐年、时间序列、月度变化等多维分析内容,目录结构清晰,便于按模块阅读和二次开发。已有328人学习或下载,适合作为课程设计、期末大作业或毕业设计原型直接参考。
1. 城市PM2.5可视化项目:为什么这类Django毕设源码值得你动手拆一遍
每年毕业季都能看到大量“基于Django的空气质量数据可视化分析”这类题目,一个包含完整源码的zip包往往被当成交差工具,解压、跑通、截图、完事。但如果你只想靠这个应付查重,你会错过一个真正能写进简历的技术样本。这个项目看起来是“爬数据+画图”,实际上把Django MTV架构、ORM建表、异步采集、ECharts前端渲染、缓存策略全串起来了。我见过太多人卡在“爬虫能跑但页面空白”“图表加载不出来”“数据库表对不上”这类问题上,根源都是只抄了代码没理解Django的请求生命周期。这篇就按我复现这类项目的路径,从造数到部署一步步拆开讲,包括那些耗了我两个晚上的玄学报错。
2. 先搭Django项目骨架:模型设计和数据落库是可视化的地基
2.1 项目结构规划:不要让所有代码都堆在根目录
按Django官方的MTV分层习惯,拿到任何源码包第一件事不是点运行,而是先看目录结构。一个合格的可视化分析项目至少要分出apps(业务模块)、static(静态资源)、templates(模板)、db.sqlite3四个部分。我见过很多初学者把视图函数全部写在views.py里,一个文件三千行,数据清洗、爬虫逻辑、业务查询全混在一起,这种代码跑到后面基本没法调试。
这个项目的合理拆分方式是这样的:
project_root/ ├── manage.py ├── pm25/ │ ├── __init__.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── apps/ │ ├── data_collect/ │ │ ├── models.py # 城市、监测点、小时浓度表 │ │ ├── views.py # 返回JSON给前端 │ │ └── urls.py │ ├── analysis/ │ │ └── utils.py # pandas聚合计算 ├── static/ │ ├── echarts/ # 本地化的echarts.min.js │ └── css/ ├── templates/ │ └── index.html └── db.sqlite3上面的pm25是Django默认项目目录,apps是我习惯用来区分业务模块的约定。注意apps目录下每个业务模块都要有__init__.py,否则Django无法识别这是一个应用。一个常被忽略的细节是:静态文件目录里的ECharts库我强烈建议下载到本地,因为可视化页面加载不出来的原因一半是CDN被墙或加载慢,一半才是代码bug。
2.2 设计PM2.5浓度数据模型:字段类型和索引决定了查询速度
接下来要做的是创建数据表模型。PM2.5监测数据最常见的字段包括城市名称、监测站点、AQI指数、PM2.5浓度(微克/立方米)、PM10浓度、空气质量等级、采集时间。用Django的ORM把这些字段映射成模型类是第一步,但字段类型的选择直接决定了后面查询的效率。
比如pm25_value这个字段,有人喜欢用FloatField存带一位小数的浓度,但实测发现国内公开数据源返回的PM2.5浓度基本都是整数,用IntegerField就够了,还省空间。再比如collect_time字段,如果要做按小时分组的趋势图,建议加上db_index=True,否则数据量到几万条以后,filter(collect_time__range=...)这种查询会明显变慢。
# apps/data_collect/models.py from django.db import models class City(models.Model): """城市基础信息表""" name = models.CharField(max_length=50, unique=True, verbose_name='城市名') province = models.CharField(max_length=50, blank=True, verbose_name='所属省份') lng = models.FloatField(default=0, verbose_name='经度') lat = models.FloatField(default=0, verbose_name='纬度') created_at = models.DateTimeField(auto_now_add=True) class Meta: db_table = 'city' ordering = ['-created_at'] verbose_name = '城市' class AirQuality(models.Model): """小时级空气质量数据""" city = models.ForeignKey(City, on_delete=models.CASCADE, related_name='aqi_data') station = models.CharField(max_length=100, blank=True, verbose_name='监测站点') pm25 = models.IntegerField(default=0, verbose_name='PM2.5浓度(μg/m³)') pm10 = models.IntegerField(default=0, verbose_name='PM10浓度(μg/m³)') aqi = models.IntegerField(default=0, verbose_name='AQI指数') level = models.CharField(max_length=20, blank=True, verbose_name='空气质量等级') collect_time = models.DateTimeField(db_index=True, verbose_name='采集时间') class Meta: db_table = 'air_quality' ordering = ['collect_time'] verbose_name = '空气质量数据'这里有个关键点:on_delete=models.CASCADE意味着删除城市会连带删除该城市的所有监测数据。做课程设计一般没问题,但如果你后续接的是真实监测设备数据流,建议改用SET_NULL并允许空值。另外注意related_name='aqi_data'这个参数,它允许你在City实例上用city.aqi_data.all()直接取出所有数据,反查起来比默认的airquality_set直观得多。
模型的Meta类里db_table是显式指定表名,不指定的话Django会按“应用名_模型名”自动生成表名,例如此处会是data_collect_air_quality。显式指定表名的好处是你后面用原生SQL写统计分析时不会记错表名。
3. 数据采集与清洗:把公开接口的数据变成数据库里的干净记录
3.1 自己写采集脚本还是用现成API?先聊聊数据源选型
做可视化分析必须得有数据,这个项目的原料是城市的PM2.5历史浓度。常见的数据获取方式有三种:第一种是爬取空气质量发布平台(如中国空气质量在线监测分析平台)的公开网页;第二种是调用第三方天气API(和风天气、心知天气等),注册后拿一个token直接请求JSON;第三种是自己用模拟数据生成器造假数据。
我一般会建议课程设计优先用第二种——公开API返回的JSON结构非常规整,没有登录验证码和反爬,而网页爬取要处理HTML解析、请求头伪装、频率限制。唯一的代价是免费API的调用次数有限,但做毕业设计演示完全够了。如果你选第一种爬虫方案,请注意限速,手动在time.sleep(5)以上。
下面这段代码是用requests直接请求和风天气API拿PM2.5数据并存入Django的开发脚本,写成一个独立的Django管理命令是最规范的,这样python manage.py collect_data就能执行:
# apps/data_collect/management/commands/collect_data.py import requests from datetime import datetime from django.core.management.base import BaseCommand from apps.data_collect.models import City, AirQuality class Command(BaseCommand): help = '从和风天气API采集指定城市的PM2.5数据' def handle(self, *args, **options): api_key = '你的key' cities = City.objects.all()[:5] # 先采集前5个城市 for city in cities: url = 'https://devapi.qweather.com/v7/air/now' params = { 'location': f'{city.lng},{city.lat}', # 用经纬度定位 'key': api_key, 'type': 'aqi' } try: resp = requests.get(url, params=params, timeout=10) data = resp.json() if data.get('code') != '200': self.stderr.write(f'{city.name} 接口返回异常:{data.get("code")}') continue now_data = data['now'] AirQuality.objects.create( city=city, station=now_data.get('station', ''), pm25=int(now_data.get('pm2p5', 0)), pm10=int(now_data.get('pm10', 0)), aqi=int(now_data.get('aqi', 0)), level=now_data.get('category', ''), collect_time=datetime.now().replace(second=0, microsecond=0) ) self.stdout.write(f'{city.name} 采集成功,PM2.5={now_data.get("pm2p5")}') except requests.exceptions.Timeout: self.stderr.write(f'{city.name} 请求超时,跳过') except ValueError: self.stderr.write(f'{city.name} JSON解析失败,可能是接口限流')Django管理命令脚本有几点必须说明:BaseCommand的handle方法就是执行入口,self.stdout.write是规范化的日志输出,红色报错用self.stderr.write(在终端里是红字)。关键逻辑在AirQuality.objects.create这一行——每执行一次脚本就插入一条记录,如果你要采集历史数据,注意别把collect_time写成datetime.now(),因为按小时补历史数据时应该用历史时刻,否则后面画时间轴会乱。
参数方面,timeout=10是给requests库设置的超时秒数,防止某个城市的接口卡死整个脚本。replace(second=0, microsecond=0)是去掉秒和微秒,保证同一个小时内重复采集不会出现时间戳精度不同导致的重复数据。被try包住的resp.json()在接口返回非JSON内容时会抛出ValueError,这个在实际运行中非常常见,免费接口经常返回一行报错文本。
3.2 数据去重和清洗:为什么insert进去的是重复脏数据
采集脚本跑起来很容易,但数据质量才是可视化项目的隐形评分点。我第一次做这个项目就踩了重复数据的坑:定时任务每10分钟跑一次采集,一个月积累了十几万条记录,画趋势图的时候曲线毛刺多到没法看,因为同一时刻存在多条记录,pandas聚合时直接翻车。
去重逻辑应该在模型层就设计好,而不是等查数据的时候才处理。最可靠的办法是做联合唯一约束:
class Meta: db_table = 'air_quality' ordering = ['collect_time'] constraints = [ models.UniqueConstraint( fields=['city', 'collect_time'], name='unique_city_time' ), ]加了约束之后,如果再采集到同一城市同一分钟的数据会直接报错,你在脚本里对IntegrityError做异常捕获就行。此外清洗维度还要看空气质量等级字段,同一个AQI值在不同标准下对应不同等级,部分API返回的等级文字和中国的国标不一致。稳妥做法是自己在代码里根据AQI区间重算等级,不要信接口的category字段。
4. 从数据库到前端图表:接口响应和ECharts配置的全链路
4.1 视图函数返回JSON还是渲染模板?按页面刷新频率决定
可视化项目的核心页面是展示趋势图和排名柱状图。Django有两种做法:第一种是模板渲染,后端把数据塞进context,页面用<script>var data = {{ data|safe }}</script>传给ECharts;第二种是后端只提供JSON接口,前端用fetch请求后动态渲染。
两种我都实测过,结论很明确:第二种更接近真实企业级数据可视化项目。原因在于当你需要在页面上做日期范围筛选、城市切换时,第一种方案会导致整个页面刷新,体验很差;而JSON接口配合前端事件绑定能做到局部更新。而且面试官问起来,“前后端分离”这个点本身就是加分项。下面是一个典型的可视化接口视图:
# apps/data_collect/views.py from django.http import JsonResponse from django.views import View from django.db.models.functions import TruncHour from django.db.models import Avg, Max from apps.data_collect.models import AirQuality class PM25TrendAPI(View): """返回指定城市24小时PM2.5趋势数据""" def get(self, request): city_id = request.GET.get('city_id', 1) hours = request.GET.get('hours', 24) # 按小时取平均浓度 rows = ( AirQuality.objects .filter(city_id=city_id) .values('collect_time') .annotate( hour=TruncHour('collect_time'), avg_pm25=Avg('pm25') ) .order_by('-hour')[:int(hours)] ) # 倒序回来方便前端直接按时间正序渲染 rows = list(reversed(rows)) time_axis = [row['hour'].strftime('%m-%d %H:00') for row in rows] pm25_values = [round(row['avg_pm25'], 1) for row in rows] return JsonResponse({ 'code': 200, 'data': { 'times': time_axis, 'pm25': pm25_values, 'city_id': int(city_id) } })这段代码的核心在TruncHour这个数据库函数:它把DATETIME类型的collect_time按小时截断,配合annotate做分组聚合,SQL层面就完成了“同一小时取平均”的动作,完全不需要在Python里循环手动处理。注意values('collect_time')的作用是告诉ORM“我要按这个字段分组”,实际上Django生成的SQL是GROUP BY原始时间字段——这有个坑:如果原始数据存在秒级差异,分出来的组会不一样,所以模型设计时强制秒数归零就很重要。
[:int(hours)]这段切片是在数据库层面执行LIMIT,而不是把全部数据查回来再截取,这是Django查询集懒加载的优势。如果你要做一个24小时趋势,实际请求的数据库记录只有24条,压力很小。
4.2 ECharts配置:折线图、柱状图的参数总结
后端接口写好后,前端就是ECharts的配置问题。可视化大屏最常见的两类图是时间轴折线图和城市排名柱状图。下面这段是templates/index.html里折线图的完整配置:
<!-- templates/index.html --> <div id="trendChart" style="width: 100%; height: 400px;"></div> <script src="{% static 'echarts/echarts.min.js' %}"></script> <script> fetch('/api/trend/?city_id=1&hours=24') .then(res => res.json()) .then(data => { const chart = echarts.init(document.getElementById('trendChart')); chart.setOption({ tooltip: { trigger: 'axis' }, grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true }, xAxis: { type: 'category', data: data.data.times, boundaryGap: false, axisLabel: { rotate: 30 } }, yAxis: { type: 'value', name: 'PM2.5 (μg/m³)', scale: true }, series: [{ name: 'PM2.5浓度', type: 'line', smooth: true, symbol: 'circle', symbolSize: 6, data: data.data.pm25, areaStyle: { opacity: 0.2 }, lineStyle: { width: 2 } }] }); }); </script>ECharts的参数有几个最容易出界面的细节:boundaryGap: false让折线从第一个点开始贴边绘制,不移半个坐标点,这对时间轴尤其重要;axisLabel的rotate: 30解决24个时间标签横排重叠的问题;smooth: true会让曲线变得圆滑,但如果数据波动剧烈,过度平滑会被质疑数据造假。yAxis里加scale: true的意思是Y轴不从0开始,而是根据数据范围自适应,这样PM2.5浓度在30到60之间波动时曲线振幅大,视觉上更明显。
还有一点容易忽视:echarts.init的容器必须要有明确宽度和高度,否则初始化出来是空白。很多人在Django模板里把图表容器放在display:none的tab页中,结果点击切换时图表不显示或尺寸错乱——解决思路是切换后调用chart.resize()。
5. 避坑指南:Django可视化项目最常见的五个翻车现场
5.1 静态文件404:CSS、JS加载不出来
现象:页面HTML渲染正常,但浏览器控制台一堆404,图表区域空白。 原因:Django开发环境默认由django.contrib.staticfiles应用处理静态文件,但必须在settings.py里配置STATIC_URL和STATICFILES_DIRS两个参数。很多源码zip里只配了STATIC_URL='/static/',忘了把项目本地static目录加入搜索路径。 解决:在settings.py中显式添加:
STATIC_URL = '/static/' STATICFILES_DIRS = [ BASE_DIR / 'static', ]另外模板里引用静态文件务必用{% load static %}加{% static 'path' %}写法,不要直接写死/static/echarts/echarts.min.js。
5.2 数据库迁移报错:表已存在或字段缺失
现象:python manage.py migrate报django.db.utils.ProgrammingError: table "air_quality" already exists,或者提示Unknown column 'level'。 原因:这类源码包通常附带db.sqlite3文件,里面可能已经有历史表结构。Django迁移系统通过django_migrations表记录执行过的迁移,当你改了模型但没生成新迁移,直接跑migrate会跳过makemigrations新产生的变更。 解决:如果不需要保留数据库里的历史数据,直接把db.sqlite3删掉,然后重新执行makemigrations和migrate。如果需要保留,用makemigrations --name add_field_level生成一个独立迁移,再migrate。
5.3 AQI等级显示None:前端图表有数据但文字空
现象:数据能展示,下拉列表或悬浮提示里的空气质量等级是空字符串。 原因:第三方接口返回的字段名不一定叫level,有的叫category,有的叫quality。代码里取错了键名,模型字段又允许blank=True,所以静默存入空值。 解决:写数据清洗函数时先打印一次完整响应体,把实际字段名核对一遍。我的经验是兼容所有可能性:
level = now_data.get('category') or now_data.get('quality') or ''5.4 时区问题导致日期差8小时
现象:前端展示趋势图,最后一个点不是当前时刻,而是当前时刻前8小时。 原因:Django的USE_TZ=True时,数据库存的是UTC时间;前端JS的new Date()按本地时区解析,如果接口返回的时间字符串不带时区后缀,浏览器会按本机时区解释,造成偏移。 解决:视图函数里查询时间字段时,用timezone.localtime()转成东八区再格式化:
from django.utils import timezone hour_label = timezone.localtime(row['hour']).strftime('%m-%d %H:00')5.5 历史数据量大时图表加载卡死
现象:选“近30天”或“近一年”的维度,页面请求接口要等十几秒才出图。 原因:把几万条原始数据全部序列化返回给前端,ECharts一次性渲染上万个点。 解决:接口层面做降采样,按天聚合、按小时聚合,而不是返回全部明细。查询用TruncDay替代TruncHour,数据量缩小24倍,视觉上趋势几乎不变。
6. 部署与进阶:把Django项目跑在公网服务器上的三个关键步骤
6.1 用uWSGI+Nginx把Django跑起来
本地跑通之后,你要把这套可视化系统部署到Linux服务器上给评委演示。Django自带的开发服务器绝不能用于生产,常见部署是Nginx反向代理加uWSGI。核心命令如下:
# 在服务器项目根目录下启动uWSGI,监听9000端口 uwsgi --http :9000 --module pm25.wsgi --processes 4 --threads 2 --stats :9191--module pm25.wsgi指向项目里pm25/wsgi.py这个入口。--processes 4表示启动4个子进程处理并发请求,如果你服务器只有1核CPU,这个值设2就够了——之前我在2核4G的阿里云主机上设了8个进程,内存直接飙到2.2G,页面加载反而变慢。
Nginx里做一个简单的反向代理,把80端口流量转发到uWSGI的9000端口:
server { listen 80; server_name your_domain_or_ip; location /static/ { alias /project_root/static/; } location / { include uwsgi_params; uwsgi_pass 127.0.0.1:9000; } }6.2 采集任务的自动化:用cron定时调度还是直接写死?
数据可视化项目最难的不是画图,而是让数据持续更新。如果只有一个静态的db.sqlite3文件,评委一问你今天的趋势怎么没有,场面会很尴尬。常见做法是写一条crontab任务,每小时跑一次采集脚本:
0 * * * * cd /project_root && python manage.py collect_data >> logs/data.log 2>&1>> logs/data.log 2>&1是把正常日志和报错都追加到同一个文件里,方便第二天排查接口限流问题。注意crontab的环境变量和登录shell不一致,脚本里要用python的绝对路径,或者先source虚拟环境激活文件。
6.3 给可视化加分的一个技巧:页面加载时的缓存策略
最后补一个让项目性能提升一个档次的技巧。可视化接口如果每次请求都实时查数据库,在并发访问时会快速暴露性能问题。用Django的cache_page装饰器可以轻松把接口响应缓存一段时间:
from django.views.decorators.cache import cache_page @cache_page(60 * 30) # 缓存30分钟 class PM25TrendAPI(View): def get(self, request): # 原有查询逻辑不变这行装饰器会让同一城市30分钟内的所有请求直接走Django缓存框架,数据库零压力。默认的缓存后端是本地内存,如果你是多进程部署,建议把缓存后端换成Redis,否则每个进程各缓存一份,效果减半。
我在这个项目上最大的教训是:可视化展示的“好看”永远排在“数据可信”后面。早期为了图面华丽,把平滑曲线和数据堆叠效果开到最大,结果曲线和实际监测值对不上。后来调整成展示原始平均浓度,接口里保留数据更新时间戳,页面越朴素,反而是答辩时质疑最少的那一版。建议你把数据采集时间、数据来源、数据口径这三个字段做成页面底部的小字标注,专业感比任何炫技配色都管用。可视化的本质是让数据说话,不是让图表替数据演戏。希望这些经验能让你少走几个弯路。
本文还有配套的精品资源,点击获取