简介:一套面向环保部门的全国空气质量指数实时可视化监测系统,采用前端Vue与后端Django框架协作构建,实现从多源数据抓取、污染热力图渲染到历史趋势分析和多城市对比的完整闭环。压缩包共收录97个文件,其中以36个JavaScript脚本、18个Vue组件为核心逻辑,配合SCSS样式、SVG和PNG图标,以及后端配置文件与静态资源,整体仅1.34MB,便于快速部署与二次开发。目前已有73人学习参考,适合环保数据可视化、前后端分离项目实践及毕业设计等场景。资源中除完整可运行代码外,还提供说明文件与附带文档,能帮助使用者理解从API请求到前端渲染的数据流,同时覆盖移动端适配、响应式布局等关键实现细节。对于想快速搭建空气质量监测平台或学习Django+Vue整合方案的开发者,这是一份具有直接参考价值的落地资源。
1. 全国空气质量实时可视化监测系统要解决的核心问题
把全国 300 多个城市实时空气质量摆到一个屏幕上的难点,不在于地图画得有多酷,而在于代码得同时扛住三件事:上游数据每小时的更新压力、前端渲染污染热力图时的视觉可读性、以及环保部门对“某城市最近 24 小时趋势”随时会提出来的查询。从 Vue.js + Django 这对组合看,这套空气质量实时可视化监测系统把职责分得非常干脆——Django 做空气质量数据采集、清洗、存储和聚合接口,Vue.js 接接口渲染热力图、趋势线和城市对比图。这套架构在环境监测、数据可视化大屏和政务类看板里都是常见形态,适合要对接真实空气监测数据、又不想把前端页面写死的团队。新手按这个结构把端到端链路跑通一遍,基本就能摸清前后端分离项目的完整套路。
2. Vue.js 与 Django 前后端分离的数据链路设计与工程骨架
2.1 为什么选 Django 承接空气质量数据采集
空气质量项目的核心不是接口写得花,而是数据模型稳定、采集任务不丢数、聚合查询不卡。Django 自带 ORM、数据库迁移机制和 admin 管理后台,这三个能力在一个会持续运行数月、还要接受环保部门查数的项目里就是刚需。另外 Python 的数据处理生态对上游数据源的清洗也友好,requests 抓数、pandas 补空值、再用 Django ORM 批量写入,链路比 Node 后端更顺。
| 对比项 | Django + DRF | Flask | Node/Express |
|---|---|---|---|
| 模型建表与迁移 | 内置 ORM,迁移脚本自动生成 | 需自行接 SQLAlchemy,代码量大 | 需手写 SQL 或另接 ORM |
| 后台管理 | admin 开箱即用 | 需另配 flask-admin | 需 react-admin 等前端方案 |
| 定时采集任务 | 管理命令 + cron 即可 | 需 celery 或 APScheduler | 需 node-cron 或 pm2 |
| 权限与多用户 | 自带 User/Permission | 需扩展 | 需另写中间件 |
从表里能看出,Django 在“有明确表单、权限、报表需求”的项目里效率最高。空气质量监测恰好就是这种项目——城市、测点、时间、污染物浓度,字段天然规整,查询维度固定,Django 的 ORM 聚合函数直接覆盖需求,不必引入额外组件。
2.2 为什么用 Vue.js 做可视化前端
Vue.js 在可视化项目里更友好的是组件化拆分方式:污染热力图独立成一个地图组件,历史数据趋势独立成一个折线组件,多城市对比独立成一个柱状组件,组件之间通过 props 传数据、通过 emit 通知父级刷新。相比直接在 HTML 里写 ECharts 初始化,这种模式在页面图表增多后不会失控。
响应式设计所需的断点控制和 resize 监听也能在 Vue 组件生命周期里统一管理。ECharts 实例在onMounted里创建,在onUnmounted里销毁,窗口变化时在resize事件里调用chart.resize(),这套逻辑天然适合 Vue 的组件化写法。再加上 Vue Router 的页面切换和 Vite 的本地开发代理,前后端分离联调时不用处理跨域问题,开发体验比传统模板渲染顺不少。
2.3 工程骨架与目录规划
常见做法是先建 Django 后端,再初始化 Vue 前端,两边独立开发后用代理联通。下面是最小可运行的工程初始化步骤:
# 1. 新建 Django 项目骨架 django-admin startproject air_quality_platform cd air_quality_platform python manage.py startapp monitor # 2. 初始化 Vue3 前端工程(Vite 脚手架) npm create vue@latest frontend cd frontend npm install后端里monitor这个 app 专门负责空气质量相关的模型和接口;前端的src/views下放 Dashboard 主页面,src/components下放地图、折线、柱状图三个图表组件。这么划分之后,后端加接口、前端换图表,互不干扰。Vite 默认端口是 5173,Django 开发服务器默认 8000,联调时让 Vite 把/api代理到http://127.0.0.1:8000即可。
2.4 前后端分离的接口约定
接口设计上不用过度设计,三个接口就能覆盖一个看板页的数据需求:
| 接口路径 | 作用 | 关键参数 |
|---|---|---|
GET /api/air-quality/latest/ | 获取所有城市最新一条 AQI | 无 |
GET /api/air-quality/history/ | 获取单城市历史趋势 | city,hours |
GET /api/air-quality/compare/ | 多城市对比 | cities(逗号分隔) |
响应统一包一层{ "code": 0, "data": [...] },时间字段统一用 ISO 8601 字符串,数值字段允许为null但不要缺 key。这套约定让前端拿到数据后不需要再做兜底解析,直接把 payload 喂给 ECharts 的 series.data 就行。
3. Django 空气质量数据采集、模型设计与查询接口实现
3.1 空气质量数据表设计
空气质量的存储核心是城市维度和时间维度。城市表保存名称与坐标,用于前端地图定位;空气质量表保存每个城市在不同时刻的 AQI、PM2.5、PM10 等字段,时间字段加索引。模型代码如下:
# monitor/models.py from django.db import models class City(models.Model): name = models.CharField('城市名', max_length=50, unique=True) province = models.CharField('省份', max_length=50, blank=True) lng = models.FloatField('经度') lat = models.FloatField('纬度') def __str__(self): return self.name class AirQuality(models.Model): city = models.ForeignKey(City, on_delete=models.CASCADE, related_name='records') aqi = models.IntegerField('AQI指数') pm25 = models.FloatField('PM2.5') pm10 = models.FloatField('PM10', null=True) quality = models.CharField('等级', max_length=20, blank=True) timestamp = models.DateTimeField('采集时间', db_index=True) class Meta: ordering = ['-timestamp'] unique_together = [('city', 'timestamp')]模型里最关键的是unique_together = [('city', 'timestamp')],它保证同一城市同一时刻只有一条数据。没有这个约束,定时任务误跑两次就会产生重复记录,前端趋势图会出现同一个时间点两条值,折线直接画成锯齿。timestamp加db_index=True是因为历史趋势查询必然按时间过滤,索引能明显提速。
建表后把模型注册进 admin,环保部门可以直接在后台按城市、时间过滤查看原始数据,不用每人配一个数据库客户端:
# monitor/admin.py from django.contrib import admin from .models import City, AirQuality @admin.register(City) class CityAdmin(admin.ModelAdmin): list_display = ['name', 'province', 'lng', 'lat'] @admin.register(AirQuality) class AirQualityAdmin(admin.ModelAdmin): list_display = ['city', 'aqi', 'pm25', 'quality', 'timestamp'] list_filter = ['city', 'quality'] date_hierarchy = 'timestamp'date_hierarchy = 'timestamp'会在后台列表页顶部生成一个按年月日逐级筛选的入口,数据量大了之后查某一天的数据不需要手写过滤条件,这是优化后台体验成本最低的一行配置。
3.2 定时采集任务:管理命令 + cron
空气质量数据采集适合用 Django 管理命令实现,而不是写进 views 里。因为采集是后台任务,不需要 HTTP 触发,管理命令可以直接被 cron 调用,也能手动执行验证。下面是采集命令的核心结构:
# monitor/management/commands/collect_air_quality.py import requests from django.core.management.base import BaseCommand from django.utils import timezone from monitor.models import City, AirQuality class Command(BaseCommand): help = '采集全国各城市空气质量数据' def handle(self, *args, **options): # 对接数据服务商提供的统一查询接口 payload = requests.get( 'https://data.example.com/api/aqi/realtime', params={'key': 'your-token'}, timeout=10 ).json() city_map = {c.name: c for c in City.objects.all()} bulk_list = [] for item in payload['list']: city = city_map.get(item['city']) if not city: continue bulk_list.append(AirQuality( city=city, aqi=item['aqi'], pm25=item.get('pm25') or 0, pm10=item.get('pm10'), quality=item.get('quality', ''), timestamp=timezone.now().replace(second=0, microsecond=0) )) AirQuality.objects.bulk_create( bulk_list, batch_size=500, ignore_conflicts=True ) self.stdout.write(self.style.SUCCESS( f'写入 {len(bulk_list)} 条记录' ))采集逻辑说明:先用requests.get从数据服务商拉取实时数据,timeout=10防止上游接口挂起导致采集卡死;然后一次性批量写入数据库。ignore_conflicts=True配合模型里的唯一约束,重复执行同一小时的任务时直接跳过已存在的记录,不会报错也不会产生脏数据。
定时调度用 crontab 即可,每 15 分钟跑一次能保证看板上的“实时”体验:
*/15 * * * * cd /opt/air_quality_platform && python manage.py collect_air_quality >> logs/collect.log 2>&1这里有个容易忽略的坑:Django 管理命令必须在该虚拟环境激活状态下执行,直接用系统 Python 跑会报ModuleNotFoundError: No module named 'django'。cron 里建议写全虚拟环境路径,比如/opt/venv/air/bin/python,避免环境变量不一致导致任务静默失败。
3.3 查询接口:最新数据与历史趋势
查询接口用 Django REST Framework 的 APIView 来写,结构清晰,后续加权限、限流都方便。先定义序列化器:
# monitor/serializers.py from rest_framework import serializers from .models import AirQuality, City class AirQualitySerializer(serializers.ModelSerializer): city = serializers.CharField(source='city.name') class Meta: model = AirQuality fields = ['city', 'aqi', 'pm25', 'pm10', 'quality', 'timestamp']接口视图里最值得注意的是latest的查询写法。由于 AirQuality 是全量记录表,每个城市每小时都有一条数据,直接order_by('-timestamp')再切片会把全表参与排序,毫无必要。最快的方式是用distinct加子查询取每个城市最新一条:
# monitor/views.py from django.db.models import Max from rest_framework.views import APIView from rest_framework.response import Response from .models import AirQuality, City class LatestAirQuality(APIView): def get(self, request): latest_time = ( AirQuality.objects .values('city_id') .annotate(max_time=Max('timestamp')) ) records = ( AirQuality.objects .select_related('city') .filter( timestamp__in=[r['max_time'] for r in latest_time] ) .order_by('city__name') ) serializer = AirQualitySerializer(records, many=True) return Response({'code': 0, 'data': serializer.data})annotate(max_time=Max('timestamp'))先在数据库层面按城市分组算出每个城市最新采集时间,再用timestamp__in精确取回对应的完整记录。这样即使采集任务某次漏跑,latest返回的依然是每个城市各自的最新数据,而不是统一时间点的数据。
3.4 跨域配置与 admin 后台优化
前后端分离开发时,Vue 跑在 5173 端口,Django 跑在 8000 端口,浏览器直接跨域。开发阶段最简单的处理是用 django-cors-headers:
pip install django-cors-headers# settings.py INSTALLED_APPS += ['corsheaders'] MIDDLEWARE.insert(0, 'corsheaders.middleware.CorsMiddleware') CORS_ALLOWED_ORIGINS = [ 'http://localhost:5173', 'http://127.0.0.1:5173', ]生产环境不建议把CORS_ALLOW_ALL_ORIGINS设成 True。环保部门内部系统大多通过域名访问,把域名列表写死在CORS_ALLOWED_ORIGINS里,既避免安全隐患,也便于之后加白名单时回溯改动记录。
4. Vue.js 污染热力图、趋势分析与多城市对比图表的落地代码
4.1 前端引入 ECharts 并准备地图数据
可视化部分依赖 ECharts 5。安装方式和按需引入代码如下:
npm install echarts@5// main.js import { createApp } from 'vue'; import * as echarts from 'echarts'; import App from './App.vue'; const app = createApp(App); app.config.globalProperties.$echarts = echarts; app.mount('#app');空气质量项目里用到的地图、折线、柱状图都属于高频组件,直接全量引入 ECharts 不仅代码简单,后续加散点图、仪表盘也不用再改引入逻辑。污染热力图还需要中国地图的地理坐标数据,ECharts 5 默认不带中国地图,需要单独引入注册:
import chinaGeoJson from '@/assets/china.json'; echarts.registerMap('china', chinaGeoJson);4.2 全国污染热力图渲染
热力图的本质是在地图上按经纬度散点标出颜色深浅。ECharts 里用series.type: 'map'配合visualMap实现。完整组件如下:
<!-- src/components/AirQualityMap.vue --> <template> <div ref="chartRef" class="map-chart"></div> </template> <script setup> import { ref, onMounted, onUnmounted, watch } from 'vue'; import * as echarts from 'echarts'; const props = defineProps({ cityList: { type: Array, required: true } }); const chartRef = ref(null); let chart = null; const renderChart = () => { const data = props.cityList.map(item => ({ name: item.city, value: [item.lng, item.lat, item.aqi] })); chart.setOption({ tooltip: { trigger: 'item', formatter: params => `${params.name}<br/>AQI: ${params.value ? params.value[2] : '-'}` }, visualMap: { min: 0, max: 300, dimension: 2, inRange: { color: ['#00d4c9', '#ffe173', '#ff6b57'] }, text: ['高', '低'], realtime: false }, geo: { map: 'china', roam: true, zoom: 1.2, itemStyle: { borderColor: '#3a4a5a', areaColor: '#1b2735' } }, series: [{ type: 'map', geoIndex: 0, data }] }); }; onMounted(() => { chart = echarts.init(chartRef.value); renderChart(); }); onUnmounted(() => { chart && chart.dispose(); }); watch(() => props.cityList, renderChart, { deep: true }); </script>组件逻辑说明:visualMap的dimension: 2表示取数据数组中的第三项(下标 2,即 AQI 值)作为颜色映射依据;realtime: false让拖拽图例时只在松开鼠标后重绘,避免频繁渲染造成卡顿;geoIndex: 0表示系列复用geo里注册的地图配置,这样 series 里不需要重复设置map相关样式。
roam: true允许拖拽缩放地图。做可视化大屏时建议把zoom初始值设在 1.1~1.3,避免南海诸岛的小图影响整体视觉比例。
4.3 历史数据趋势折线图
趋势分析组件接收后端返回的时间序列,核心是把时间点映射到 x 轴:
<!-- src/components/AqiTrendLine.vue --> <script setup> import { ref, onMounted, watch } from 'vue'; import * as echarts from 'echarts'; const props = defineProps({ history: { type: Array, required: true } }); const chartRef = ref(null); let chart = null; const render = () => { const times = props.history.map(r => r.timestamp.slice(5, 16)); const values = props.history.map(r => r.aqi); chart.setOption({ xAxis: { type: 'category', data: times }, yAxis: { type: 'value', name: 'AQI' }, series: [{ type: 'line', smooth: true, data: values, areaStyle: { opacity: 0.15 } }] }); }; onMounted(() => { chart = echarts.init(chartRef.value); render(); }); watch(() => props.history, render, { deep: true }); </script>slice(5, 16)是把2025-04-08 14:00:00截成04-08 14:00,横轴不至于太长。趋势图组件不建议在内部做轮询,数据刷新逻辑应放在父级 Dashboard 中统一控制,子组件只负责响应数据变化重绘。
4.4 多城市对比柱状图
多城市对比最常见的做法是选中几个城市,看同一时刻的 AQI 值并排对比。Vue 里用多选下拉框收集城市列表,把数据转化为并列柱状图:
<script setup> const props = defineProps({ records: { type: Array, required: true } }); const render = () => { const aqiMap = {}; props.records.forEach(r => { aqiMap[r.city] = r.aqi; }); chart.setOption({ xAxis: { type: 'category', data: Object.keys(aqiMap) }, yAxis: { type: 'value' }, series: [{ type: 'bar', data: Object.values(aqiMap), label: { show: true, position: 'top' } }] }); }; </script>多城市对比的图表选型有一个经验:城市数量大于等于 5 个时,并列柱状图会变得拥挤,此时改成横向柱状图或雷达图可读性更好。三种图表的适用场景对比如下:
| 图表类型 | 适合场景 | 注意事项 |
|---|---|---|
| 热力图 | 全国城市整体分布 | 数据量 300+ 城市时渲染流畅 |
| 折线图 | 单城市 24 小时趋势 | 时间轴过密时开启sampling: 'lttb' |
| 柱状图 | 多城市横向对比 | 城市多时改用横向条形图 |
折线图数据超过 100 个点时,ECharts 默认不会降采样,会出现渲染卡顿。显式加上sampling: 'lttb'可以在保留趋势特征的前提下压缩点数,这是历史数据跨天查询时最容易忽略的性能参数。
5. 移动端适配、响应式布局和联调阶段需要验证的 3 个细节
5.1 看板页面响应式断点设计
环保部门现在大量使用平板和大屏,移动端适配不能只做缩放,要按设备宽度重新排布。Dashboard 主布局用 CSS Grid,在断点切换列数:
.dashboard-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; } @media (max-width: 768px) { .dashboard-grid { grid-template-columns: 1fr; } } .dashboard-title { font-size: clamp(18px, 4vw, 28px); }平板宽度下保留两列,手机宽度下压成一列。clamp(18px, 4vw, 28px)让标题字号在 18~28px 间随视口平滑变化,避免单独写多套字号。
5.2 ECharts resize 防抖处理
移动端页面转屏、浏览器窗口缩放都会触发 resize 事件。图表没有自适应调用时会变形。标准写法是防抖监听:
import { debounce } from 'lodash-es'; const onResize = debounce(() => { chart && chart.resize(); }, 150); onMounted(() => window.addEventListener('resize', onResize)); onUnmounted(() => window.removeEventListener('resize', onResize));防抖时间设 150ms 比较合适,既跟手又不会在连续拖拽窗口时密集触发重绘。另外移动端有横竖屏切换事件orientationchange,在部分安卓机型上不会触发 window resize,需要一并绑定:
window.addEventListener('orientationchange', onResize);5.3 联调时必做的接口验证
前端代理配置好之后,先用 curl 验证后端接口连通性,再开页面。这个顺序能区分问题出在前端还是后端:
curl -s "http://127.0.0.1:8000/api/air-quality/latest/" | head -c 300返回 JSON 里code为 0 且data数组非空,说明接口正常。然后检查 Vue 代理:
// vite.config.js server: { proxy: { '/api': { target: 'http://127.0.0.1:8000', changeOrigin: true } } }页面访问http://127.0.0.1:5173时,请求/api/xxx会被 Vite 代理到 Django。手机真机调试时,Vite 默认监听localhost,需要用--host启动,让手机通过局域网 IP 访问,同时 Django 的ALLOWED_HOSTS要临时加入局域网 IP,否则后端会拒绝请求。
本文还有配套的精品资源,点击获取