基于Django的民宿数据可视化系统:从数据清洗到ECharts仪表盘

发布时间:2026/10/18 12:18:10
基于Django的民宿数据可视化系统:从数据清洗到ECharts仪表盘
简介基于Python与Django框架打造的民宿房源数据分析可视化系统属于完整毕业设计源码包面向计算机相关专业学生与需要完成课程设计、期末大作业的开发者。系统聚焦民宿房源数据的采集、清洗、存储与可视化展示包含后台管理、统计图表、地图展示等模块兼顾学习与二次开发需求。压缩包共807个文件以321个JavaScript、147个CSS、80个HTML等前端资源以及40个Python源码文件为主另有少量配置文件与说明文档整体体积约11.88MB结构清晰便于按功能定位代码。目前已有774人学习下载运行环境已调试妥当下载解压后即可导入运行。借助完整源码读者可深入理解Django项目分层设计、数据可视化实现思路与民宿业务场景的数据分析流程也可作为论文写作与答辩演示的支撑素材。1. 民宿数据躺在Excel里没人看这套Django可视化系统要解决什么毕设和课程设计里最常出现的翻车现场不是算法不够深而是数据拉出来了、图也画了但评委一问“哪个区的房源均价涨了、哪类房间评分集中度高”就答不上来。这套基于Django的民宿房源数据分析可视化系统核心价值是把散落的房源记录变成能直接回答经营问题的图表面板城市分布、价格分层、评分与评论量关系、区域热力对比一屏看全。它面向Python数据分析课设、Django课程设计和期末大作业后端走Django前端用ECharts出图界面样式偏MDUI风格源码完整跑起来就是一个拿得出手的交互式数据看板。适合三类人需要完整毕设Demo的在校生、第一次搞Django全栈想少踩坑的初学者、手头有真实房源数据想快速搭内部看板的从业者。2. 数据建模与ETL把散落的民宿记录整理成可分析的结构拿到源码包之后第一步不是急着跑python manage.py runserver而是先把数据链路摸清楚。这套系统的数据源头有两条一条是手工维护的CSV房源明细另一条是项目里自带的Scrapy采集模块生成的抓取结果。无论哪条路进来最终都要落到Django的ORM模型里。如果你直接跳过建模去看图表后面调字段、补数据时就得反复改表血泪经验。2.1 民宿房源的核心字段与建模思路民宿数据和普通电商数据不太一样它的分析维度集中在三块位置城市、区域、经纬度、价格每晚单价、价格带、口碑评分、评论数、回复率。建模时把这几个观察维度拆开查询才能不出冤枉路。from django.db import models class House(models.Model): house_id models.CharField(房源ID, max_length64, uniqueTrue, db_indexTrue) title models.CharField(标题, max_length255) city models.CharField(城市, max_length32, db_indexTrue) district models.CharField(区域, max_length64, blankTrue) longitude models.DecimalField(经度, max_digits9, decimal_places6) latitude models.DecimalField(纬度, max_digits9, decimal_places6) price models.DecimalField(每晚价格, max_digits8, decimal_places2, db_indexTrue) score models.FloatField(评分, default0.0) comment_count models.IntegerField(评论数, default0) house_type models.CharField(房源类型, max_length16, choices( (entire, 整套), (single, 单间), ), defaultsingle) created_at models.DateTimeField(采集时间, auto_now_addTrue) class Meta: ordering [-created_at]这段模型设计有三个值得留意的点。house_id加了uniqueTrue和db_indexTrue因为民宿平台的数据重复采集很常见不建唯一索引爬虫多跑两轮就会出现同一套房源两个ID的脏数据。经纬度用DecimalField而不是FloatField是因为浮点精度在地图热力聚合时会产生肉眼可见的坐标偏移这对后续区域聚合是硬伤。price也做了db_index价格区间筛选是这个系统使用频率最高的查询条件没有索引的话数据量到万级就会明显变慢。2.2 数据清洗与标准化三个最容易出错的地方建模完成后下一步是把原始CSV或者Scrapy结果清洗成能入库的结构。我这里用的是一段独立的数据预处理脚本放在manage.py同级的scripts/目录下用pandas处理避免在Django shell里写面条代码。import pandas as pd df pd.read_csv(raw_houses.csv, encodingutf-8-sig) # 价格列经常混着货币符号和中文先做规范化 df[price] df[price].astype(str).str.replace(r[¥,元], , regexTrue) df[price] pd.to_numeric(df[price], errorscoerce) # 商品评分缺失值用城市均价兜底不直接填0 city_mean df.groupby(city)[score].transform(mean) df[score] df[score].fillna(city_mean.round(1)) # 经纬度越界记录直接剔除保留异常值只会让热力图白一块 df df[(df[longitude] 73) (df[longitude] 135)] df df[(df[latitude] 3) (df[latitude] 53)] df df.dropna(subset[price, longitude, latitude]) df.to_csv(cleaned_houses.csv, indexFalse, encodingutf-8-sig)这段脚本里encodingutf-8-sig是第一个坑的解决方案——Windows下导出的CSV通常是GBK编码直接用utf-8读会整个乱码加utf-8-sig能同时兼容带不带BOM的文件。errorscoerce是处理价格列的兜底方式转不出来的值变成NaN不会中断脚本执行。评分缺失用同城市均值填充而不是填0是为了后续评分聚合时不产生向下的偏差。经纬度范围过滤则是防脏数据入库的最后一道门。2.3 自定义管理命令让数据入库变得可重复清洗完的数据不能手动loaddata因为CSV不是Django原生的fixture格式。我一般会写一个自定义management command把导入步骤固化下来这样换机器、换数据源也能一键重跑。from django.core.management.base import BaseCommand import pandas as pd from apps.house.models import House class Command(BaseCommand): help 从清洗后的CSV导入民宿数据 def add_arguments(self, parser): parser.add_argument(--csv, typestr, defaultcleaned_houses.csv) parser.add_argument(--batch, typeint, default2000) def handle(self, *args, **options): df pd.read_csv(options[csv], encodingutf-8-sig) batch [] for _, row in df.iterrows(): batch.append(House( house_idrow[house_id], titlerow[title], cityrow[city], districtrow.get(district, ), longituderow[longitude], latituderow[latitude], pricerow[price], scorerow[score], comment_countrow.get(comment_count, 0), )) if len(batch) options[batch]: House.objects.bulk_create(batch, ignore_conflictsTrue) batch.clear() if batch: House.objects.bulk_create(batch, ignore_conflictsTrue) self.stdout.write(self.style.SUCCESS(导入完成))这段命令用bulk_create批量写入比循环save()快一个量级万级数据基本几秒结束。ignore_conflictsTrue配合模型里的uniqueTrue重复的house_id会静默跳过相当于天然去重。--batch参数控制每批写入数量2000是个比较平衡的值太小请求次数多太大容易在MySQL上触发包大小限制。提示执行导入命令前先确保django-extensions已安装或直接在项目根目录用python manage.py import_houses --csv cleaned_houses.csv调用依赖关系在requirements.txt里都有。3. 视图与接口设计后端如何把数据喂给前端图表数据入库只是地基真正决定这个系统“好不好看”的关键是视图层返回的JSON结构能不能直接匹配图表组件。很多毕设翻车在这里前端拿到的数据结构和ECharts需要的格式对不上然后所有人在模板里用JS硬改数据格式改到怀疑人生。3.1 用ORM聚合替代全量查询城市、价格带、评分三个维度的统计这个系统里最核心的接口不是返回明细而是返回聚合结果。如果直接把上万条房源数据塞给前端页面首屏会卡到3秒以上。正确做法是在后端用ORM的annotate和values完成分组统计。from django.db.models import Count, Avg from django.http import JsonResponse from apps.house.models import House def price_bucket_summary(request): # 按价格带分桶0-200, 201-400, 401-600, 600 buckets { 经济型(0-200): (0, 200), 舒适型(201-400): (201, 400), 轻奢型(401-600): (401, 600), 高端型(600): (601, 1_000_000), } result [] for label, (low, high) in buckets.items(): qs House.objects.filter(price__gtelow, price__lthigh) row qs.aggregate( totalCount(id), avg_scoreAvg(score), avg_commentAvg(comment_count), ) result.append({bucket: label, **row}) return JsonResponse({code: 0, data: result})这种写法的好处是逻辑透明每个区间独立聚合排错时能直接看出哪个桶的数据有问题。但要注意filteraggregate会在每次循环里各发一次SQL查询区间固定四个所以可接受如果以后要动态分桶就得用CaseWhen做单次查询。价格分桶的边界用gt/lt成对出现避免400元的房源同时落入两个桶这是数据分析里常见的边界错误。3.2 城市级别的地理聚合与热力图数据格式地理热力图需要的是经纬度坐标加权重值。ECharts的scatter和effectScatter接受[longitude, latitude, value]三元组我直接让后端生成这个结构前端零处理。def city_geo_data(request): rows (House.objects .values(city, longitude, latitude) .annotate(totalCount(id), avg_priceAvg(price)) .order_by(-total)[:500]) items [{ name: r[city], value: [float(r[longitude]), float(r[latitude]), r[total]], avgPrice: round(r[avg_price], 2), } for r in rows] return JsonResponse({code: 0, data: items})有人问为什么不用annotate直接把所有城市一次算完还要order_by(-total)[:500]。原因是全量返回会造成地图上重叠的点互相遮挡而且折线图、散点图都吃不下这么大数组。后端先按房源数排序取Top 500既保证视觉密度又给前端留了缩放空间。float(longitude)这步不能省因为DecimalField取出来是Decimal对象json.dumps序列化时会直接报错这是接口开发里的经典隐形坑。3.3 筛选联动接口用URL参数驱动图表刷新仪表盘上的筛选器不能每动一下就刷新整个页面那样体验太原始。这个项目采用的是标准的“URL参数 接口重新请求”模式视图里用request.GET接收筛选条件前端用fetch带参请求。def filtered_summary(request): city request.GET.get(city, ) min_price request.GET.get(min_price, typeint) max_price request.GET.get(max_price, typeint) min_score request.GET.get(min_score, typefloat) qs House.objects.all() if city and city ! 全部: qs qs.filter(citycity) if min_price is not None: qs qs.filter(price__gtemin_price) if max_price is not None: qs qs.filter(price__ltemax_price) if min_score is not None: qs qs.filter(score__gtemin_score) data qs.aggregate(totalCount(id), avg_priceAvg(price)) by_type list(qs.values(house_type) .annotate(countCount(id), avg_pAvg(price))) return JsonResponse({code: 0, total: data[total], avgPrice: round(data[avg_price] or 0, 2), byType: by_type})request.GET.get(min_price, typeint)这种写法里Django会帮你做类型转换转失败就返回None比手动int()包try/except干净。注意avg_price用了or 0因为数据被筛选为空时聚合结果是None直接传回前端会出现NaN显示这在ECharts里表现为图表断档。4. 可视化仪表盘从HTML模板到ECharts图表的完整链路后端接口就绪之后前端才是这系统最直观的门面。我打开模板文件时看到项目同时引入了bootstrap.min.css和mdui.min.css实际布局走的是MDUI风格。该踩的坑一点不少图表容器高度为0、JSON数据被转义、刷新时图表闪烁这些都是对接阶段的高频问题。4.1 用内置模板标签把JSON数据安全送进前端最笨但也最稳的做法是利用Django的json_script过滤器把一个Python数据结构直接渲染成页面上的JSON脚本块。!-- templates/dashboard.html -- div idpriceChart styleheight:420px;/div {{ price_bucket_data | json_script:price-bucket-data }}// static/js/dashboard.js const raw document.getElementById(price-bucket-data); const bucketData JSON.parse(raw.textContent); const chart echarts.init(document.getElementById(priceChart)); chart.setOption({ tooltip: { trigger: axis }, xAxis: { type: category, data: bucketData.map(d d.bucket) }, yAxis: { type: value }, series: [{ name: 房源数量, type: bar, data: bucketData.map(d d.total), itemStyle: { color: #5470c6 } }] });json_script是Django 2.1以后内置的模板过滤器它比{{ data }}直接插值安全得多——后者会被HTML转义导致JS字符串里出现quot;之类的实体字符。用json_script时取值必须用raw.textContent而不是innerHTML后者会触发浏览器对脚本内容的重新解析极端情况下会执行到恶意内容。4.2 筛选器与图表联动下拉框 区间滑块的提交流程仪表盘顶部有三个控件城市下拉框、价格区间滑块、评分最小值输入框。它们的联动不依赖Vue或React就是用原生事件绑定重新请求接口然后原地更新图表配置。const citySel document.getElementById(citySelect); const priceRange document.getElementById(priceRange); const refreshBtn document.getElementById(refreshBtn); async function refreshAll() { const params new URLSearchParams(); if (citySel.value ! 全部) params.set(city, citySel.value); const [min, max] priceRange.value.split(,).map(Number); params.set(min_price, min); params.set(max_price, max); const resp await fetch(/api/summary?${params.toString()}); const json await resp.json(); if (json.code ! 0) { console.error(接口报错, json); return; } updateTypePie(json.byType); updateTotalCard(json.total); updateAvgCard(json.avgPrice); } refreshBtn.addEventListener(click, refreshAll);这里有一个不起眼但关键的细节priceRange.value.split(,)拿到的可能是空字符串Number()是0这会意外把最小价格带过滤成0。我实际的做法是先判断min是否为0且滑块没动过再用min || undefined跳过空参数。否则用户打开页面不做任何操作看到的就已经被过滤过的数据了。4.3 图表自适应与窗口大小变化ECharts生成的图表容器窗口缩放以后不会自动撑满。这个项目里我加了一个监听事件避免图表在宽屏显示器上只占半边。window.addEventListener(resize, () { chart.resize(); heatChart.resize(); pieChart.resize(); });如果是多图表页面遍历一个实例数组统一处理比每个resize写一行更强壮。自适应是无伤大雅的小优化但答辩演示时投影仪分辨率切换导致图表变形就是靠这个兜住的。5. 常见问题与排查跑不起来、图表空白、中文乱码的四个高频坑源码包里的项目在最初接入时我至少遇到六个环境级别的坑下面是四个必然遇到的按破坏力排序每一条都是现象、原因、解决三段式展开。5.1 现象一数据库迁移后表创建失败报“字段超长”或“Duplicate column name”现象执行python manage.py migrate时直接报错有时在python manage.py makemigrations的环节就提示字段冲突。原因项目最初是MySQL开发的本地换成了SQLite而模型中某些CharField的长度或DateTimeField的默认值在不同数据库引擎下表现不一致部分迁移文件残留了之前的字段定义重复执行时产生了Duplicate column冲突。解决先把db.sqlite3删掉重来然后清掉每个app的migrations/目录里除__init__.py外的文件再重新makemigrations和migrate。注意如果项目依赖MySQL还要在settings.py里确认数据库方言SQLite和MySQL在JSON字段、索引长度上的兼容性差异是这类报错的元凶。5.2 现象二图表区域空白浏览器控制台报404且找不到静态资源现象页面能打开但所有图表一个都不显示F12看到dashboard.js或者echarts.min.js返回404。原因项目里static目录路径没有配好STATICFILES_DIRS没有指向实际存放JS/CSS的目录或者DEBUGFalse时Django不再自动托管静态文件。解决在settings.py里确认/static/前缀和磁盘目录的对应关系。STATIC_URL /static/ STATICFILES_DIRS [ BASE_DIR / static, ] # 生产环境或演示环境可临时开启DEBUG省去collectstatic步骤 DEBUG True顺带一提加BASE_DIR / static用的是pathlib如果你用的Django版本比较老要改成os.path.join(BASE_DIR, static)否则直接TypeError。5.3 现象三CSV导入后中文全部变乱码页面表格显示锟斤拷现象用管理命令导入CSV后页面上所有城市名、区域名都是乱码。原因CSV文件本身是GBK或者GB2312编码而读取时用了默认的utf-8解码失败后产生乱码。解决如前文代码所示在pd.read_csv里显式指定encodingutf-8-sig。如果文件是GBK则改成encodinggbk或者先用一个探码的小函数自动检测with open(raw_houses.csv, rb) as f: raw f.read(1024) enc gbk if b\xb1\xbe in raw else utf-8-sig5.4 现象四接口数据量一大页面刷新要5秒以上现象当房源表里有几万行数据时首页或者热力图接口响应时间从几百毫秒暴涨到几秒。原因接口里直接用了list(House.objects.all())或者全量values()再在Python里做统计数据库索引没生效。解决把统计逻辑迁到ORM聚合上同时给city、price补上db_index该优化在前文模型里已经预留了。# 错误写法全量取出再统计 all_houses list(House.objects.values(city, price)) # 正确写法数据库端分组聚合 House.objects.values(city).annotate(totalCount(id)).order_by(-total)另外接口返回前用cache.set做10分钟缓存房源数据本身不是实时强一致的缓存带来的性能提升比任何查询优化都明显。6. 进阶玩法把静态看板升级成可下钻的数据探索工具基础可视化做完之后这个系统的天花板在于“筛选→联动→下钻”的探索链路。我习惯加一个“区域下钻”功能城市热力图点中某个区域下方图表立即切换为该区域内房源的价格分布和评分分布。这里的核心实现很简单就是复用已有的filtered_summary接口前端传入区域参数。heatChart.on(click, params { const region params.name; const params new URLSearchParams({ district: region }); fetch(/api/summary?${params}) .then(r r.json()) .then(json updateDetailCharts(json)); });后端只需在filtered_summary里多接收一个district参数在过滤链路上追加qs qs.filter(districtdistrict)即可加了这一行整个系统交互深度就上了一个台阶。如果你的数据里有check_in_date字段还能再加上按日期的入住热度趋势但民宿订房数据的口径要单独清洗别混在一起。线下演示的时候我习惯先把浏览器窗口调成窄屏再拉宽模拟答辩时投影仪比例变化强迫图表自适应逻辑跑一遍。凡是做过一次交付给学弟他拿到手第一件事不去跑runserver而是打开requirements.txt对着版本查依赖就说明他已经吃过了“环境不一致”的亏。从那以后我每次拿到一份新的毕设源码都强制自己走一遍“建虚拟环境 → 装依赖 → 迁移 → 导数据 → 起服务”的无脑流程确认全程无痛才敢说“这个项目能跑”。完整源码包里各个模块都已经就位照着上面流程理顺相信你能少走两个星期的弯路希望帮到你。本文还有配套的精品资源点击获取