
我做了几个旅游类的数据可视化项目之后发现很多团队的实际需求其实是类似的数据量不算海量用不上大数据平台但业务方又不想在Excel里看一堆数字想要一张能“讲故事”的动态大屏。这篇文章就从一个真实可落地的项目出发拆解如何用 Django 做后端数据服务配 ECharts 做前端可视化在两周内搭出一套旅游数据分析可视化系统。内容包含数据库表设计、ORM 聚合查询、API 接口封装、大屏图表实现以及部署时容易踩的坑适合有一定 Python 基础、想做完整 Django 实战项目的开发者参考。1. 项目思路与整体架构设计1.1 这个系统到底解决什么问题旅游行业的数据有个特点维度多、杂、散。有景区维度的客流和门票收入有时间维度的淡旺季波动有客源维度的来源地分布还有游客满意度、停留时长这些软性指标。地方政府文旅局、景区运营公司、旅行社这三类角色关心的数据侧重点各不相同但如果只拿原始数据表格出来沟通成本很高。这套系统的定位很明确把旅游数据从 Excel 表格里解放出来做成一个“一屏观全局”的可视化分析平台。它解决的不是能不能算出来的问题而是能不能一眼看懂的问题。我给它规划了四个核心能力景区热度实时排行当日/当月客流前10名景区用横向柱状图展示。游客来源地分析按省份统计游客占比用中国地图或饼图呈现。时间趋势分析月度客流和收入趋势用折线图反映淡旺季规律。核心指标卡总游客量、总收入、平均满意度、平均停留时长做头部摘要。这些需求扔给任何一个BI工具比如Power BI、帆软都能做为什么还要用 Django 自己搭主要原因是数据源可能要对接景区票务系统、OTA平台接口数据处理逻辑复杂而且后续可能要做预测模型、爬虫采集等定制功能用 Django 可以保持业务逻辑和展示层全部在自己掌控范围内改起来灵活也方便二次开发。1.2 技术选型为什么要用这套组合先说我最终选型再讲理由组件选型用途Web框架Django 4.2后端服务、ORM、Admin后台前端框架Bootstrap 5 jQuery页面布局与请求封装可视化库ECharts 5所有图表渲染数据库MySQL 8.0业务数据存储缓存Django Cache Redis大屏数据缓存加速服务器Nginx Gunicorn生产环境部署Django 在这个项目里几乎是标准答案级别的选择。第一点它内置了 Admin 后台景区信息表、游客数据表可以直接通过后台增删改查等于免费拿到了一个数据维护系统省去写一堆表单页面的时间。第二点它的 ORM 对聚合统计的支持足够强按月份分组、按景区分组、多表关联统计这些需求用annotate加values就能搞定不用写裸 SQL。第三点Django 的模板系统直接把 ECharts 页面渲染出来不需要做前后端分离省去了 CORS 跨域和 token 鉴权的繁琐工作。ECharts 选得也没什么悬念。在国内做数据可视化ECharts 的文档和社区成熟度是最高的百度地图、中国地图这些组件开箱即用。而且它对移动端适配做得不错大屏缩放到小屏幕上也不会太崩。相比之下D3.js 学习成本高Highcharts 商业授权要钱Chart.js 的图表类型和交互深度不如 ECharts 丰富。数据库我选了 MySQL 而非 SQLite理由是这类系统后期大概率要对接多端数据源SQLite 在并发写入上是短板而且 MySQL 的分组、日期函数和 ECharts 需要的数据格式匹配度更好。1.3 整体架构与数据流向这套系统的架构非常简洁核心就三层数据采集/导入 → Django ORM → 聚合查询API → ECharts渲染数据采集层初期用 Mock 数据生成脚本模拟一年的旅游数据约5万条后期可以替换为爬虫采集或第三方接口对接。数据经过清洗后写入 MySQL。业务逻辑层Django 通过 ORM 执行聚合查询对景区、时间、客源等多个维度分组统计将结果整理成JSON结构按API形式抛出。展示层前端页面通过fetch或jQuery.ajax请求API拿到 JSON 数据后传给 ECharts 的option配置渲染出大屏图表。这个架构的巧妙之处在于后端只负责吐数据不关心图表长什么样前端只负责画图不关心数据是怎么算出来的。解耦带来的好处是哪天想换图表库或者想挪到微信小程序展示后端代码一行都不用改。2. 环境准备与Django工程搭建2.1 环境准备与依赖安装创建项目前先把虚拟环境准备好。我用的是 Python 3.10用venv创建独立环境避免和系统Python环境互相污染。python3 -m venv tourism_env source tourism_env/bin/activate pip install django4.2.15 mysqlclient pillow redis有两个细节可以留意一下。第一mysqlclient这个包在 macOS 或者 Linux 上安装前需要先装 MySQL 开发库不然会编译报错。Ubuntu 上用sudo apt-get install default-libmysqlclient-dev能解决Windows 上如果没有现成的编译环境直接换成pymysql并在 Django 的__init__.py里加上pymysql.install_as_MySQLdb()更省事。第二Redis 缓存是可选的但强烈建议加上。大屏页面的数据接口会被前端每10秒轮询一次如果每次都实时查 MySQL数据库压力不小。把聚合结果缓存到 Redis10分钟内数据不重算响应时间能从 200ms 降到 30ms 左右。2.2 创建项目和app依赖装好之后创建工程和应用django-admin startproject tourism_analysis cd tourism_analysis python manage.py startapp analysis python manage.py startapp visualization我习惯把项目拆成两个appanalysis负责数据模型、数据清洗、聚合查询这是项目的心脏。visualization负责页面渲染、图表API接口、静态资源管理这是项目的门面。这种拆分在刚开始看起来有点过度设计项目小的时候一个app也能搞定。但旅游数据分析系统后期大概率会加预测模块、报告导出模块app模块化之后扩展边界非常清晰。我自己早期做Django新手项目时就吃过亏所有views、models、utils堆在一个app里三个月后再看根本不想维护。配置settings.py时需要把两个app加进INSTALLED_APPS并配置数据库连接DATABASES { default: { ENGINE: django.db.backends.mysql, NAME: tourism_db, USER: root, PASSWORD: your_password, HOST: 127.0.0.1, PORT: 3306, OPTIONS: {charset: utf8mb4}, } } # Redis缓存配置 CACHES { default: { BACKEND: django_redis.cache.RedisCache, LOCATION: redis://127.0.0.1:6379/1, OPTIONS: {CLIENT_CLASS: django_redis.client.DefaultClient}, } }charset配置成utf8mb4是防止景区名称里出现生僻字或emoji符号时写入报错这个坑我在之前一个项目里踩过数据库表建好之后再来改字符集非常麻烦。2.3 数据建模与迁移数据模型是整个系统的地基。我设计了张业务表景区信息表ScenicSpot和游客统计表TouristFlow。ScenicSpot存景区的静态属性比如名称、所在城市、景区等级、门票价格TouristFlow存每天每个景区的动态统计数据比如游客数量、收入、客源省份、满意度评分。两张表通过外键关联。from django.db import models class ScenicSpot(models.Model): name models.CharField(max_length100, verbose_name景区名称) city models.CharField(max_length50, verbose_name所在城市) level models.CharField(max_length10, verbose_name景区等级, choices[(5A, 5A级), (4A, 4A级), (3A, 3A级)]) ticket_price models.DecimalField(max_digits6, decimal_places2, verbose_name门票价格) class Meta: verbose_name 景区信息 ordering [id] class TouristFlow(models.Model): scenic_spot models.ForeignKey(ScenicSpot, on_deletemodels.CASCADE, verbose_name景区) visit_date models.DateField(verbose_name统计日期) tourist_count models.IntegerField(verbose_name游客数量) revenue models.DecimalField(max_digits12, decimal_places2, verbose_name当日收入) province models.CharField(max_length50, verbose_name客源省份) satisfaction models.FloatField(verbose_name满意度评分) stay_hours models.FloatField(verbose_name平均停留时长) class Meta: verbose_name 游客统计 ordering [-visit_date] indexes [ models.Index(fields[visit_date, province]), ]建表之后执行迁移python manage.py makemigrations python manage.py migrate这里特别说明一下为什么要设置联合索引。TouristFlow这张表一旦接入真实数据每天都要按日期和省份过滤是全表扫描还是走索引性能差距可以到几十倍。对visit_date和province建联合索引WHERE visit_date BETWEEN ... AND ... AND province北京这类查询的效率会高很多。3. 数据分析与API接口实现3.1 分析维度与业务指标设计做可视化之前一定要先把业务指标梳理清楚否则图表画得再好看也只是花架子。我从业务方的角度把分析拆成4个维度时间维度天、月、年三个粒度重点看季节性波动和同比环比。景区维度单个景区排行、同城景区对比。客源维度跨省游客占比、省内省外比例。质量维度满意度、停留时长、人均消费。围绕这四个维度输出六张核心图表没做更多因为大屏不是报表图多了反而抓不住重点顶部指标卡总游客量、总收入、满意度均值、人均消费。月度客流趋势折线图双线客流 收入。景区热度排行横向柱状图。客源省份占比饼图。城市客流对比柱状图。满意度雷达图。3.2 数据生成与预处理真实项目里数据来自景区票务系统的导出文件但开发阶段我写了一个generate_data.py脚本来生成模拟数据。这个脚本放在应用根目录下用 Django 的shell命令跑# analysis/management/commands/generate_data.py import random from datetime import date, timedelta from django.core.management.base import BaseCommand from analysis.models import ScenicSpot, TouristFlow class Command(BaseCommand): help 生成模拟旅游数据 def handle(self, *args, **options): spots list(ScenicSpot.objects.all()) start_date date(2023, 1, 1) end_date date(2023, 12, 31) provinces [北京, 上海, 广东, 江苏, 浙江, 四川, 山东, 河南, 湖北, 陕西, 湖南, 福建] for spot in spots: for i in range((end_date - start_date).days 1): current_date start_date timedelta(daysi) # 旺季5-8月10月客流更多 season_factor 1.5 if current_date.month in [5, 6, 7, 8, 10] else 1.0 # 周末客流高于工作日 weekday_factor 1.3 if current_date.weekday() 5 else 1.0 base_count random.randint(800, 3000) tourist_count int(base_count * season_factor * weekday_factor) revenue tourist_count * float(spot.ticket_price) * random.uniform(0.6, 1.1) TouristFlow.objects.create( scenic_spotspot, visit_datecurrent_date, tourist_counttourist_count, revenueround(revenue, 2), provincerandom.choice(provinces), satisfactionrandom.uniform(3.8, 5.0), stay_hoursrandom.uniform(2.0, 6.0), )注意这个脚本里包含了季节性因子和周末因子这是做旅游分析时很重要的业务知识——如果不做这种规则处理生成的数据就会是完全白噪声画出来的图表没有任何规律可循后面的聚合分析也就失去了意义。模拟数据也要模拟出真实的业务规律这是很多人忽略的地方。3.3 ORM聚合查询与接口代码实现数据生成完之后核心工作就是聚合查询。这里说两个关键点。第一不需要序列化器那一套。Django REST Framework 的ModelSerializer确实很香但本项目API只做读取不做写入直接构造字典返回JSON即可更轻。第二聚合查询最常用的就是valuesannotate组合。比如按月分组统计客流量from django.db.models.functions import TruncMonth from django.db.models import Sum, Avg, Count def monthly_trend(request): 月度客流与收入趋势 cache_key monthly_trend_data cached cache.get(cache_key) if cached: return JsonResponse(cached) result (TouristFlow.objects .annotate(monthTruncMonth(visit_date)) .values(month) .annotate( total_touristsSum(tourist_count), total_revenueSum(revenue), avg_satisfactionAvg(satisfaction) ) .order_by(month)) data { months: [item[month].strftime(%Y-%m) for item in result], tourists: [item[total_tourists] for item in result], revenue: [float(item[total_revenue]) for item in result], satisfaction: [round(item[avg_satisfaction], 2) for item in result] } cache.set(cache_key, data, timeout600) return JsonResponse(data)这里面的关键逻辑是annotate(monthTruncMonth(visit_date))TruncMonth函数将日期字段截断到月份精度比如2023-05-20会被归一化成2023-05-01然后再按归一化后的分组得到的就是月度聚合数据。这在SQL层面相当于写成GROUP BY DATE_FORMAT(visit_date, %Y-%m-01)。景区热度排行又是另一种写法def spot_ranking(request): 景区客流排行TOP10 result (TouristFlow.objects .values(scenic_spot__name, scenic_spot__city) .annotate(total_touristsSum(tourist_count)) .order_by(-total_tourists)[:10]) data { names: [item[scenic_spot__name] for item in result], cities: [item[scenic_spot__city] for item in result], values: [item[total_tourists] for item in result] } return JsonResponse(data)注意这里用的是scenic_spot__name这种跨表字段写法Django ORM 会自动生成 JOIN 查询不需要手动去关联表。但是有一点要想清楚如果不加[:10]切片前端一次性拿到全量数据图表渲染没问题但JSON体积会大加载也会慢。TOP10这种硬需求在SQL层面就limit掉是最简单的优化手段。3.4 URL路由与接口设计接口定义时最好遵循一个规律路径里让人一眼看懂查的是什么参数里指定时间范围等条件。from django.urls import path from visualization import views urlpatterns [ path(api/dashboard/, views.dashboard_overview, namedashboard_overview), path(api/monthly-trend/, views.monthly_trend, namemonthly_trend), path(api/spot-ranking/, views.spot_ranking, namespot_ranking), path(api/province-distribution/, views.province_distribution, nameprovince_distribution), path(api/city-comparison/, views.city_comparison, namecity_comparison), path(api/satisfaction-analysis/, views.satisfaction_analysis, namesatisfaction_analysis), ]然后让浏览器直接打开接口地址测试数据是否正常返回。这一步顺手做一下接口的可视化审查比如总游客量有没有因为数据重复而翻倍、景区排行里有没有出现null名称等。接口层面还有个问题值得注意大屏系统的数据是定期轮询还是手动刷新。我推荐大屏页面用setInterval每30秒自动刷新指标卡每10秒刷新。缓存设置在600秒所以接口轮询的压力很小能扛住一整天挂在大厅屏幕上。4. 可视化大屏开发实战4.1 页面布局与图表规划先说大屏页面的视觉设计思路。旅游数据的受众领导汇报、游客中心大屏普遍有个习惯看一眼就得能总结出结论。所以布局要遵循总-分模式最重要的指标卡放最上面中间放趋势和排行两侧放分布和对比。我采用的是经典的栅格布局┌─────────────────────────────────────────────┐ │ 指标卡1 │ 指标卡2 │ 指标卡3 │ 指标卡4 │ ├──────────────┬──────────────┬───────────────┤ │ 月度客流趋势 │ 景区热度排行 │ 客源分布饼图 │ ├──────────────┴──────────────┴───────────────┤ │ 城市对比柱状图 │ 满意度雷达图 │ 数据更新状态 │ └─────────────────────────────────────────────┘表格里的这种布局就是大屏系统里最经典的dashboard结构用Bootstrap栅格完全能实现div classcontainer-fluid div classrow div classcol-md-3div classcard idcard-total.../div/div div classcol-md-3div classcard idcard-revenue.../div/div ... /div div classrow div classcol-md-6div idchart-trend classchart-box/div/div div classcol-md-3div idchart-ranking classchart-box/div/div div classcol-md-3div idchart-province classchart-box/div/div /div /div大屏背景颜色我用的深色系#0f1224图表透明背景字体用白色。对比一下深色和浅色两种方案深色大屏在展厅灯光下对比度更高领导拍照也更好看浅色主题在办公电脑上阅读更舒适。如果这套系统是内部办公用的后台分析系统选浅色主题就好挂在游客中心大屏上建议用深色。4.2 ECharts图表代码实现ECharts 5 的用法可以总结成三步init传option配置setOption。核心的难点全在option配置里。比如月度趋势折线图配置两个Y轴左轴客流、右轴收入因为客流和收入的量级不一样共用一个Y轴会让其中一条线几乎贴底看不出变化规律// 静态资源/js/dashboard.js $(document).ready(function () { var trendChart echarts.init(document.getElementById(chart-trend)); $.getJSON(/api/monthly-trend/, function (data) { trendChart.setOption({ tooltip: { trigger: axis }, legend: { data: [游客量, 旅游收入], textStyle: { color: #fff } }, grid: { left: 8%, right: 8%, bottom: 8%, containLabel: true }, xAxis: { type: category, data: data.months, axisLabel: { color: #aaa } }, yAxis: [ { type: value, name: 游客量万人, axisLabel: { formatter: function (value) { return (value / 10000).toFixed(0) 万; } } }, { type: value, name: 收入万元, axisLabel: { formatter: function (value) { return (value / 10000).toFixed(0) 万; } } } ], series: [ { name: 游客量, type: line, smooth: true, data: data.tourists, areaStyle: { opacity: 0.2 }, itemStyle: { color: #4fc3f7 } }, { name: 旅游收入, type: line, smooth: true, yAxisIndex: 1, data: data.revenue, itemStyle: { color: #ffb74d } } ] }); }); });用formatter函数把原始数值转换成万显示会让图表标签干净很多。如果不加这个处理旅游收入那儿会出现一大串零大屏美观度直接下降一个档次。景区排行柱状图建议用横向柱状图yAxis是类目xAxis是数值因为景区名称短则4个字长则8个字纵向柱状图的x轴只会挤成一团。横向柱状图还能方便地按数值大小排序一眼看出谁是第一。地图图表在这个项目里是可选的需要先加载中国地图的GeoJSON。ECharts 5.0之后地图数据已不内置需要单独引入china.js或使用 echarts 扩展网上可以找到很多现成资源。如果嫌地图数据包麻烦用饼图展示客源省份分布也一样直观。4.3 图表自适应与数据刷新大屏页面有个特殊需求经常需要在不同分辨率的屏幕上展示从32寸显示器到LED拼接屏都有可能。如果不做自适应全屏之后图表要么拉伸变形、要么留白过大。我用ECharts官方推荐的resize方案window.addEventListener(resize, function () { trendChart.resize(); rankingChart.resize(); provinceChart.resize(); });同时把图表容器设置成100%宽度固定高度或按比例计算。实际项目中32:9的超宽屏会用到grid分栏这里不展开普通16:9屏幕这套配置完全够用。自动刷新用setIntervalfunction refreshAllCharts() { $.getJSON(/api/monthly-trend/, function (data) { trendChart.setOption({ xAxis: { data: data.months }, series: [ { data: data.tourists }, { data: data.revenue } ] }); }); // 其他图表同理 } setInterval(refreshAllCharts, 30000); // 每30秒刷新一次这里有个细节刷新的时候不要重新init图表而是复用原图表实例调用setOption这样图表不会闪动。setOption可以只传变化的字段未传字段保持不变ECharts 会做 merge。如果传setOption(newOption, true)notMerge参数为true会整体替换但会导致图表重绘闪烁不建议在大屏上用。5. 常见问题排查与经验复盘5.1 ORM聚合查询的坑group by用了哪些字段用values(scenic_spot__name).annotate(Sum(tourist_count))没有问题但如果在values里多加几个字段比如values(scenic_spot__name, province)那么聚合粒度就变了——分组维度从景区变成了景区省份的组合。这个坑比较隐蔽因为代码不会报错但是结果和预期差得很远。调试这种问题有一个通用思路停掉缓存先在shell里跑查询打印str(qs.query)看生成的SQL检查GROUP BY后面跟了几个字段。如果GROUP BY字段和分组意图不一致优先修改values参数。5.2 时区导致的时间数据偏移Django 有个USE_TZTrue的默认配置MySQL 存的是UTC时间。国内时间和UTC相差8小时如果当天凌晨的业务数据被记录成UTC时间查询visit_date时就会出现一天偏差。因为这个项目统计的都是自然日数据DateField本身不涉及时区转换所以没踩到DateTimeField的坑但如果你把统计日期改成了DateTimeField在按月分组时建议在settings.py设置TIME_ZONE Asia/Shanghai并且明确USE_TZ True。如果发现数据整体偏移一天把USE_TZ设为False是最快的解决办法但代价是Django和数据库的时区逻辑都由你自己负责两边必须保持一致。5.3 可视化图表细节中文标签、字体和大数字旅游数据分析系统里图表文字全是中文有几个细节直接影响大屏的观感字体用系统默认的Microsoft YaHei或PingFang SC相比ECharts默认的无衬线字体中文渲染会清晰很多。大数字展示不要用科学计数法用JS的toLocaleString(zh-CN)给数字加千分位分隔符比如1,234,567比1234567更易读。饼图标签容易重叠配置label: { formatter: {b}\n{c} ({d}%) }分行显示名称和占比并且开启avoidLabelOverlap: true。柱状图数值过大时坐标轴标签不要全部显示设置interval: auto让ECharts自动抽稀。还有一个容易被忽略的点大屏项目通常会把页面投到电视或LED屏上如果电视长期显示固定界面会有烧屏风险。我一般会在页面右下角加一个缓慢移动的数字时钟顺带显示数据更新时间既解决了烧屏问题也解决了数据到底是不是最新的这个领导最爱问的问题。5.4 性能优化与上线清单系统上线前有几件和代码无关但必须做的事把DEBUG False不然出错页面会暴露服务器路径和配置信息。ALLOWED_HOSTS配置成实际域名或IP开发环境下经常有人忘了配生产环境一刷新就报DisallowedHost。用 Gunicorn 启动 Djangogunicorn tourism_analysis.wsgi:application -w 4 -b 127.0.0.1:8000再用 Nginx 做反向代理和静态资源托管。静态文件收集python manage.py collectstatic否则 CSS 和 JS 文件在 Nginx 下面根本找不到。性能层面我的经验判断是数据量在 10 万级以内这套架构随便跑不卡如果超过百万级建议给 MySQL 加只读从库或者把聚合结果做成定时任务crontabmanage.py shell脚本在凌晨算好之后存到独立统计表里大屏只查算好的结果不再实时跑GROUP BY。我在实际做项目时第一版就直接上了实时聚合查询页面加载一次大概要2秒。优化之后改成定时预计算Redis缓存页面加载降到300ms以内体感提升非常明显。如果你的大屏有秒开需求强烈建议走预计算这条路。写在最后回到标题说的旅游数据分析可视化系统总结下来本质是用 Django 做数据的搬运工和加工厂用 ECharts 做数据的翻译官。项目难度不在技术本身而在整理需求和把数据算对的过程。我个人实际做下来有几个体会第一模型设计一定要花时间想清楚时间维度和景区维度是主轴别急着写视图函数第二接口返回的 JSON 结构先定好前端写起来才会顺手第三模拟数据阶段就要带业务规律不然图表画出来没有说服力领导看了会说这数据是不是假的。最后再说一句项目里图表不需要贪多五到六张能说明核心问题的图远比十张没有重点的图有价值。