news 2026/10/18 12:17:52

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于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_length=64, unique=True, db_index=True) title = models.CharField('标题', max_length=255) city = models.CharField('城市', max_length=32, db_index=True) district = models.CharField('区域', max_length=64, blank=True) longitude = models.DecimalField('经度', max_digits=9, decimal_places=6) latitude = models.DecimalField('纬度', max_digits=9, decimal_places=6) price = models.DecimalField('每晚价格', max_digits=8, decimal_places=2, db_index=True) score = models.FloatField('评分', default=0.0) comment_count = models.IntegerField('评论数', default=0) house_type = models.CharField('房源类型', max_length=16, choices=( ('entire', '整套'), ('single', '单间'), ), default='single') created_at = models.DateTimeField('采集时间', auto_now_add=True) class Meta: ordering = ['-created_at']

这段模型设计有三个值得留意的点。house_id加了unique=True和db_index=True,因为民宿平台的数据重复采集很常见,不建唯一索引,爬虫多跑两轮就会出现同一套房源两个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', encoding='utf-8-sig') # 价格列经常混着货币符号和中文,先做规范化 df['price'] = df['price'].astype(str).str.replace(r'[¥¥,元]', '', regex=True) df['price'] = pd.to_numeric(df['price'], errors='coerce') # 商品评分缺失值用城市均价兜底,不直接填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', index=False, encoding='utf-8-sig')

这段脚本里encoding='utf-8-sig'是第一个坑的解决方案——Windows下导出的CSV通常是GBK编码,直接用utf-8读会整个乱码,加utf-8-sig能同时兼容带不带BOM的文件。errors='coerce'是处理价格列的兜底方式,转不出来的值变成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', type=str, default='cleaned_houses.csv') parser.add_argument('--batch', type=int, default=2000) def handle(self, *args, **options): df = pd.read_csv(options['csv'], encoding='utf-8-sig') batch = [] for _, row in df.iterrows(): batch.append(House( house_id=row['house_id'], title=row['title'], city=row['city'], district=row.get('district', ''), longitude=row['longitude'], latitude=row['latitude'], price=row['price'], score=row['score'], comment_count=row.get('comment_count', 0), )) if len(batch) >= options['batch']: House.objects.bulk_create(batch, ignore_conflicts=True) batch.clear() if batch: House.objects.bulk_create(batch, ignore_conflicts=True) self.stdout.write(self.style.SUCCESS('导入完成'))

这段命令用bulk_create批量写入,比循环save()快一个量级,万级数据基本几秒结束。ignore_conflicts=True配合模型里的unique=True,重复的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__gte=low, price__lt=high) row = qs.aggregate( total=Count('id'), avg_score=Avg('score'), avg_comment=Avg('comment_count'), ) result.append({'bucket': label, **row}) return JsonResponse({'code': 0, 'data': result})

这种写法的好处是逻辑透明,每个区间独立聚合,排错时能直接看出哪个桶的数据有问题。但要注意,filter+aggregate会在每次循环里各发一次SQL查询,区间固定四个所以可接受;如果以后要动态分桶,就得用Case+When做单次查询。价格分桶的边界用gt/lt成对出现,避免400元的房源同时落入两个桶,这是数据分析里常见的边界错误。

3.2 城市级别的地理聚合与热力图数据格式

地理热力图需要的是经纬度坐标加权重值。ECharts的scatter和effectScatter接受[longitude, latitude, value]三元组,我直接让后端生成这个结构,前端零处理。

def city_geo_data(request): rows = (House.objects .values('city', 'longitude', 'latitude') .annotate(total=Count('id'), avg_price=Avg('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', type=int) max_price = request.GET.get('max_price', type=int) min_score = request.GET.get('min_score', type=float) qs = House.objects.all() if city and city != '全部': qs = qs.filter(city=city) if min_price is not None: qs = qs.filter(price__gte=min_price) if max_price is not None: qs = qs.filter(price__lte=max_price) if min_score is not None: qs = qs.filter(score__gte=min_score) data = qs.aggregate(total=Count('id'), avg_price=Avg('price')) by_type = list(qs.values('house_type') .annotate(count=Count('id'), avg_p=Avg('price'))) return JsonResponse({'code': 0, 'total': data['total'], 'avgPrice': round(data['avg_price'] or 0, 2), 'byType': by_type})

request.GET.get('min_price', type=int)这种写法里,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 id="priceChart" style="height: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的目录,或者DEBUG=False时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里显式指定encoding='utf-8-sig'。如果文件是GBK,则改成encoding='gbk',或者先用一个探码的小函数自动检测:

with open('raw_houses.csv', 'rb') as f: raw = f.read(1024) enc = 'gbk' if b'\xb1\xbe' in raw else 'utf-8-sig'

5.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(total=Count('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(district=district)即可,加了这一行,整个系统交互深度就上了一个台阶。如果你的数据里有check_in_date字段,还能再加上按日期的入住热度趋势,但民宿订房数据的口径要单独清洗,别混在一起。

线下演示的时候,我习惯先把浏览器窗口调成窄屏再拉宽,模拟答辩时投影仪比例变化,强迫图表自适应逻辑跑一遍。凡是做过一次交付给学弟,他拿到手第一件事不去跑runserver,而是打开requirements.txt对着版本查依赖,就说明他已经吃过了“环境不一致”的亏。从那以后我每次拿到一份新的毕设源码,都强制自己走一遍“建虚拟环境 → 装依赖 → 迁移 → 导数据 → 起服务”的无脑流程,确认全程无痛才敢说“这个项目能跑”。完整源码包里各个模块都已经就位,照着上面流程理顺,相信你能少走两个星期的弯路,希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/18 12:17:47

2026年免费大模型API盘点:30+平台白嫖指南,TaoToken统一Key接入实测

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/18 12:17:14

Phalcon 中文文档全中文手册:快速上手与避坑指南

简介&#xff1a;这份 Phalcon 中文文档全中文手册面向 PHP 开发者&#xff0c;尤其是希望从传统框架转向高性能 C 扩展框架的中高级工程师&#xff0c;解决官方英文资料阅读门槛高、知识点分散的问题。手册系统覆盖安装配置、路由、控制器与视图、模型与数据库操作、缓存与性能…

作者头像 李华
网站建设 2026/10/18 12:16:17

中国电信计算机岗笔试:计算机网络题库精讲与高频考点解析

简介&#xff1a;这份资料围绕中国电信计算机岗位笔试常见考点整理而成&#xff0c;面向正在准备电信运营商校招或社招的技术类考生&#xff0c;尤其适合需要系统复习计算机网络基础的人群。内容以经典笔试习题与知识点详解为主&#xff0c;覆盖网络分类、电路交换与分组交换对…

作者头像 李华
网站建设 2026/10/18 12:13:02

STM32+LwIP+CLion实现DHCP热插拔稳定联网

简介&#xff1a;本资源是一套基于STM32F407ZGTx的嵌入式以太网开发完整工程&#xff0c;面向具备HAL库和CMake基础的中级嵌入式开发者&#xff0c;解决STM32在Clion环境下集成LWIP协议栈并实现DHCP动态获取IP及网线热插拔检测的实际工程难题。项目涵盖STM32CubeMX图形化配置&a…

作者头像 李华
网站建设 2026/10/18 12:12:16

GPT-6.1 Sol Ultrafast 实测:推理速度、成本与API接入全解析

最近这一个来月&#xff0c;我一直在跟新的模型版本较劲。GPT-6.1 Sol Ultrafast 上线那天&#xff0c;我刚跑完一组长上下文压测&#xff0c;本以为又是一次常规迭代&#xff0c;仔细看完文档之后才发现这代模型在推理速度和接口设计上做了些很不一样的东西。很多朋友在后台问…

作者头像 李华
网站建设 2026/10/18 12:12:00

AI虚拟娱乐的文化坐标:从架构层面构建文化适配能力

1. AI虚拟娱乐的文化坐标&#xff1a;从一个偶然发现说起做AI应用架构这几年&#xff0c;我有一个越来越明确的感受&#xff1a;技术问题做到最后&#xff0c;往往都是文化问题。这个念头最早是在调试一个面向多个地区用户的虚拟角色互动系统时冒出来的。当时我们团队在给一个A…

作者头像 李华